From cec7fb677111a41701a8b9ae0d98c24be8b064a1 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:08:34 -0700 Subject: [PATCH 01/47] chore(deps): bump MapLibre to 6.28.0 - Raise the exact pin from 6.25.1 (held for iPad landscape blur) - Drop MetalLayerScaleFix; upstream #4373 fixes native scale --- MC1/Views/Map/MC1MapView.swift | 134 +-------------------------------- project.yml | 2 +- 2 files changed, 2 insertions(+), 134 deletions(-) diff --git a/MC1/Views/Map/MC1MapView.swift b/MC1/Views/Map/MC1MapView.swift index a5efc90e7..9e2fffb53 100644 --- a/MC1/Views/Map/MC1MapView.swift +++ b/MC1/Views/Map/MC1MapView.swift @@ -1,142 +1,10 @@ import MapKit import MapLibre -import ObjectiveC import OSLog import SwiftUI private let logger = Logger(subsystem: "com.mc1", category: "MapPins") -// MARK: - MapLibre Metal scale fix - -/// Workaround for a MapLibre bug where `MLNEffectiveScaleFactorForView` -/// computes `nativeBounds.width / bounds.width` — a ratio that breaks in -/// landscape because `nativeBounds` is fixed while `bounds` rotates. -/// We intercept both `setDrawableSize:` and `setContentScaleFactor:` on -/// 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 - } - - 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 - } - 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 - } - - 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) - } - } - - 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 - } - - 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) - } -} - struct MC1MapView: UIViewRepresentable { // Data let points: [MapPoint] @@ -406,7 +274,7 @@ 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)) + let mapView = MLNMapView(frame: CGRect(x: 0, y: 0, width: 1, height: 1)) // Callbacks var onPointTap: ((MapPoint, CGPoint) -> Void)? diff --git a/project.yml b/project.yml index 90185ceae..a49c94579 100644 --- a/project.yml +++ b/project.yml @@ -27,7 +27,7 @@ packages: from: 1.5.0 MapLibre: url: https://github.com/maplibre/maplibre-gl-native-distribution - exactVersion: 6.25.1 + exactVersion: 6.28.0 MessagingUI: url: https://github.com/bwees-forks/swiftui-messaging-ui # Pinned to an exact revision: Package.resolved is gitignored (the whole From 7232a01e63fa5b16cc4ccd1e6adefab486df327b Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:56:14 -0700 Subject: [PATCH 02/47] feat(remote-nodes): size neighbor key hex by hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clamp unresolved identity hex to 2–3 bytes from device hashSize - Capture width on neighbours response so disconnect does not reflow labels - Widen colliding unplottable map titles to the full prefix - Add resolver, view model, and map builder coverage --- MC1/Utilities/RepeaterResolver.swift | 19 +++- MC1/Views/RemoteNodes/NeighborRow.swift | 13 ++- .../Repeaters/NeighborSNRMapBuilder.swift | 30 ++++- .../Repeaters/NeighborSNRMapView.swift | 15 ++- .../Repeaters/RepeaterSettingsView.swift | 3 +- .../Repeaters/RepeaterStatusContent.swift | 9 +- .../Repeaters/RepeaterStatusView.swift | 3 +- .../Repeaters/RepeaterStatusViewModel.swift | 14 ++- .../Utilities/RepeaterResolverTests.swift | 29 +++++ ...RemoteNodeStatusHandlerSurvivalTests.swift | 9 +- .../RepeaterStatusViewModelTests.swift | 49 ++++++++ .../NeighborSNRMapBuilderTests.swift | 105 +++++++++++++++--- 12 files changed, 261 insertions(+), 37 deletions(-) diff --git a/MC1/Utilities/RepeaterResolver.swift b/MC1/Utilities/RepeaterResolver.swift index a32cc7d1e..8c2f3217b 100644 --- a/MC1/Utilities/RepeaterResolver.swift +++ b/MC1/Utilities/RepeaterResolver.swift @@ -218,8 +218,23 @@ enum NeighborNameResolver { )?.displayName } - static func fallbackName(for prefix: Data) -> String { - prefix.prefix(4).uppercaseHexString() + static let minimumKeyDisplayByteCount = 2 + static let maximumKeyDisplayByteCount = 3 + + /// Bytes of public-key prefix to show for a neighbour identity hex string. + /// Uses `DeviceDTO.hashSize` when present, clamped between `minimumKeyDisplayByteCount` + /// and `maximumKeyDisplayByteCount`. + static func keyDisplayByteCount(deviceHashSize: Int?) -> Int { + guard let size = deviceHashSize else { return minimumKeyDisplayByteCount } + return min(max(size, minimumKeyDisplayByteCount), maximumKeyDisplayByteCount) + } + + /// Uppercase hex of the leading `byteCount` bytes. + /// Clamps to `maximumKeyDisplayByteCount` and the available prefix so the full wire + /// prefix is never shown as a fallback title. + static func fallbackName(for prefix: Data, byteCount: Int) -> String { + let n = min(max(byteCount, 0), maximumKeyDisplayByteCount, prefix.count) + return prefix.prefix(n).uppercaseHexString() } /// Resolves every hop of a node's stored path to a repeater name, falling back to a placeholder diff --git a/MC1/Views/RemoteNodes/NeighborRow.swift b/MC1/Views/RemoteNodes/NeighborRow.swift index 3ab1c65b6..ec83c774d 100644 --- a/MC1/Views/RemoteNodes/NeighborRow.swift +++ b/MC1/Views/RemoteNodes/NeighborRow.swift @@ -5,6 +5,7 @@ struct NeighborRow: View { let neighbor: NeighbourInfo let displayName: String let matchKind: NodeNameMatchKind + let keyDisplayByteCount: Int let previousNeighbor: NeighborSnapshotEntry? let isNew: Bool @@ -12,12 +13,14 @@ struct NeighborRow: View { neighbor: NeighbourInfo, displayName: String, matchKind: NodeNameMatchKind, + keyDisplayByteCount: Int, previousNeighbor: NeighborSnapshotEntry? = nil, isNew: Bool = false ) { self.neighbor = neighbor self.displayName = displayName self.matchKind = matchKind + self.keyDisplayByteCount = keyDisplayByteCount self.previousNeighbor = previousNeighbor self.isNew = isNew } @@ -46,7 +49,7 @@ struct NeighborRow: View { } HStack(spacing: 4) { - Text(firstKeyByte) + Text(keyHex) .font(.system(.caption2, design: .monospaced)) Text("·") Text(lastSeenText) @@ -75,9 +78,11 @@ struct NeighborRow: View { } } - private var firstKeyByte: String { - guard let firstByte = neighbor.publicKeyPrefix.first else { return "" } - return Data([firstByte]).uppercaseHexString() + private var keyHex: String { + NeighborNameResolver.fallbackName( + for: neighbor.publicKeyPrefix, + byteCount: keyDisplayByteCount + ) } private var lastSeenText: String { diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift index b5995f11e..8aab99017 100644 --- a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapBuilder.swift @@ -38,7 +38,8 @@ enum NeighborSNRMapBuilder { contacts: [ContactDTO], discoveredNodes: [DiscoveredNodeDTO], userLocation: CLLocation?, - filter: MapFilterState + filter: MapFilterState, + keyDisplayByteCount: Int ) -> PlottedNeighbors { let filter = filter.sanitized(for: .neighborSNR) let effectiveContacts: [ContactDTO] = if filter.favoritesOnly { @@ -80,7 +81,10 @@ enum NeighborSNRMapBuilder { ) else { unplottable.append(UnplottableNeighbor( neighbor: neighbor, - displayName: NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix), + displayName: NeighborNameResolver.fallbackName( + for: neighbor.publicKeyPrefix, + byteCount: keyDisplayByteCount + ), matchKind: .unresolved )) continue @@ -136,10 +140,30 @@ enum NeighborSNRMapBuilder { points: points, lines: lines, region: plottedCoordinates.boundingRegion(), - unplottable: unplottable + unplottable: disambiguatingUnresolved(unplottable) ) } + /// Distinct unresolved neighbours can share the clamped key prefix and look identical; + /// colliding titles widen to the full stored prefix. + private static func disambiguatingUnresolved(_ unplottable: [UnplottableNeighbor]) -> [UnplottableNeighbor] { + var titleCounts: [String: Int] = [:] + for item in unplottable where item.matchKind == .unresolved { + titleCounts[item.displayName, default: 0] += 1 + } + guard titleCounts.contains(where: { $0.value > 1 }) else { return unplottable } + return unplottable.map { item in + guard item.matchKind == .unresolved, titleCounts[item.displayName, default: 0] > 1 else { + return item + } + return UnplottableNeighbor( + neighbor: item.neighbor, + displayName: item.neighbor.publicKeyPrefix.uppercaseHexString(), + matchKind: .unresolved + ) + } + } + private static func isPlottable(_ coordinate: CLLocationCoordinate2D) -> Bool { coordinate.isValidFix } diff --git a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift index bec65d314..762ad9c3d 100644 --- a/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/NeighborSNRMapView.swift @@ -21,6 +21,9 @@ struct NeighborSNRMapView: View { let contacts: [ContactDTO] let discoveredNodes: [DiscoveredNodeDTO] let userLocation: CLLocation? + /// Hex width for unresolved identity prefixes. Captured with the neighbours fetch so map + /// titles and no-location rows use the same value. + let keyDisplayByteCount: Int @AppStorage(AppStorageKey.mapStyleSelection.rawValue) private var mapStyleSelection: MapStyleSelection = .standard @AppStorage(AppStorageKey.mapShowLabels.rawValue) private var showLabels = AppStorageKey.defaultMapShowLabels @@ -86,7 +89,10 @@ struct NeighborSNRMapView: View { .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $showingNoLocationList) { if let unplottable = plotted?.unplottable { - NeighborsNoLocationList(unplottable: unplottable) + NeighborsNoLocationList( + unplottable: unplottable, + keyDisplayByteCount: keyDisplayByteCount + ) } } .onAppear { @@ -114,7 +120,8 @@ struct NeighborSNRMapView: View { contacts: contacts, discoveredNodes: discoveredNodes, userLocation: userLocation, - filter: mapFilter + filter: mapFilter, + keyDisplayByteCount: keyDisplayByteCount ) } @@ -185,13 +192,15 @@ struct NeighborSNRMapView: View { /// unresolved). Reuses `NeighborRow`, including its "?" fallback-match affordance. private struct NeighborsNoLocationList: View { let unplottable: [NeighborSNRMapBuilder.UnplottableNeighbor] + let keyDisplayByteCount: Int var body: some View { List(unplottable, id: \.neighbor.publicKeyPrefix) { item in NeighborRow( neighbor: item.neighbor, displayName: item.displayName, - matchKind: item.matchKind + matchKind: item.matchKind, + keyDisplayByteCount: keyDisplayByteCount ) } .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.neighborsNotShown(unplottable.count)) diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift index 92751cc82..3163d7233 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift @@ -87,7 +87,8 @@ struct RepeaterSettingsView: View { statusViewModel.configure( repeaterAdminService: { appState.services?.repeaterAdminService }, contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } + nodeSnapshotService: { appState.services?.nodeSnapshotService }, + deviceHashSize: { appState.connectedDevice?.hashSize } ) Task { await statusViewModel.registerHandlers() diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift index 56a122143..476713217 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift @@ -61,7 +61,8 @@ struct RepeaterStatusContent: View { neighbors: viewModel.neighbors, contacts: contacts, discoveredNodes: discoveredNodes, - userLocation: userLocation + userLocation: userLocation, + keyDisplayByteCount: viewModel.neighborKeyDisplayByteCount ) } .themedCanvas(theme) @@ -215,6 +216,7 @@ private struct NeighborsSection: View { neighbor: neighbor, displayName: resolution?.displayName ?? L10n.RemoteNodes.RemoteNodes.Status.unknown, matchKind: resolution?.matchKind ?? .unresolved, + keyDisplayByteCount: viewModel.neighborKeyDisplayByteCount, previousNeighbor: viewModel.helper.previousNeighborSnapshot?.neighborSnapshots?.first { $0.publicKeyPrefix == neighbor.publicKeyPrefix }, @@ -236,7 +238,10 @@ private struct NeighborsSection: View { ) DisappearedNeighborRow( neighbor: old, - displayName: resolution?.displayName ?? NeighborNameResolver.fallbackName(for: old.publicKeyPrefix), + displayName: resolution?.displayName ?? NeighborNameResolver.fallbackName( + for: old.publicKeyPrefix, + byteCount: viewModel.neighborKeyDisplayByteCount + ), matchKind: resolution?.matchKind ?? .unresolved ) } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift index 72df5d6fa..b121c89d6 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift @@ -51,7 +51,8 @@ struct RepeaterStatusView: View { viewModel.configure( repeaterAdminService: { appState.services?.repeaterAdminService }, contactService: { appState.services?.contactService }, - nodeSnapshotService: { appState.services?.nodeSnapshotService } + nodeSnapshotService: { appState.services?.nodeSnapshotService }, + deviceHashSize: { appState.connectedDevice?.hashSize } ) await viewModel.registerHandlers() diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift index 21c5bbaa7..cf5418d8c 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusViewModel.swift @@ -29,6 +29,10 @@ final class RepeaterStatusViewModel { /// Error scoped to the neighbors section, kept separate from other sections' errors. var neighborsSectionError: String? + /// Hex width for neighbour identity prefixes. Set with each neighbours response so on-screen + /// identifiers keep their width if the companion disconnects while the list is shown. + var neighborKeyDisplayByteCount = NeighborNameResolver.minimumKeyDisplayByteCount + /// Discovery state var isDiscovering: Bool { discoverTask != nil @@ -64,6 +68,11 @@ final class RepeaterStatusViewModel { repeaterAdminServiceProvider() } + private var deviceHashSizeProvider: @MainActor () -> Int? = { nil } + private var deviceHashSize: Int? { + deviceHashSizeProvider() + } + // MARK: - Initialization init() {} @@ -72,9 +81,11 @@ final class RepeaterStatusViewModel { func configure( repeaterAdminService: @escaping @MainActor () -> RepeaterAdminService?, contactService: @escaping @MainActor () -> ContactService?, - nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService? + nodeSnapshotService: @escaping @MainActor () -> NodeSnapshotService?, + deviceHashSize: @escaping @MainActor () -> Int? ) { repeaterAdminServiceProvider = repeaterAdminService + deviceHashSizeProvider = deviceHashSize helper.configure( contactService: contactService, nodeSnapshotService: nodeSnapshotService @@ -163,6 +174,7 @@ final class RepeaterStatusViewModel { func handleNeighboursResponse(_ response: NeighboursResponse) async { neighbors = response.neighbours + neighborKeyDisplayByteCount = NeighborNameResolver.keyDisplayByteCount(deviceHashSize: deviceHashSize) isLoadingNeighbors = false neighborsLoaded = true diff --git a/MC1Tests/Utilities/RepeaterResolverTests.swift b/MC1Tests/Utilities/RepeaterResolverTests.swift index 011911b0d..9aebae653 100644 --- a/MC1Tests/Utilities/RepeaterResolverTests.swift +++ b/MC1Tests/Utilities/RepeaterResolverTests.swift @@ -502,4 +502,33 @@ struct RepeaterResolverTests { #expect(result?.displayName == "Newer Repeater") #expect(result?.matchKind == .fallback) } + + // MARK: - Key display length + + @Test(arguments: [ + (deviceHashSize: Int?.none, expected: 2), + (deviceHashSize: 1, expected: 2), + (deviceHashSize: 2, expected: 2), + (deviceHashSize: 3, expected: 3), + (deviceHashSize: 99, expected: 3) + ]) + func `key display byte count floors at 2 and caps at 3`(deviceHashSize: Int?, expected: Int) { + #expect(NeighborNameResolver.keyDisplayByteCount(deviceHashSize: deviceHashSize) == expected) + } + + @Test + func `fallback name with byte count formats two or three prefix bytes`() { + let prefix = Data([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + #expect(NeighborNameResolver.fallbackName(for: prefix, byteCount: 2) == "DEAD") + #expect(NeighborNameResolver.fallbackName(for: prefix, byteCount: 3) == "DEADBE") + } + + @Test + func `fallback name with byte count clamps display max and short prefixes`() { + let prefix = Data([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + #expect(NeighborNameResolver.fallbackName(for: prefix, byteCount: 6) == "DEADBE") + #expect(NeighborNameResolver.fallbackName(for: prefix, byteCount: 99) == "DEADBE") + #expect(NeighborNameResolver.fallbackName(for: Data([0xAB]), byteCount: 3) == "AB") + #expect(NeighborNameResolver.fallbackName(for: Data(), byteCount: 2) == "") + } } diff --git a/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift b/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift index 20b92c1ea..405a441f7 100644 --- a/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift +++ b/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift @@ -85,7 +85,8 @@ struct RemoteNodeStatusHandlerSurvivalTests { viewModel.configure( repeaterAdminService: { service }, contactService: { nil }, - nodeSnapshotService: { nil } + nodeSnapshotService: { nil }, + deviceHashSize: { nil } ) await viewModel.registerHandlers() @@ -152,7 +153,8 @@ struct RemoteNodeStatusHandlerSurvivalTests { viewModel.configure( repeaterAdminService: { service }, contactService: { services.contactService }, - nodeSnapshotService: { services.nodeSnapshotService } + nodeSnapshotService: { services.nodeSnapshotService }, + deviceHashSize: { nil } ) await viewModel.registerHandlers() await service.setStatusHandler { _ in statusFlag.set() } @@ -205,7 +207,8 @@ struct RemoteNodeStatusHandlerSurvivalTests { viewModel.configure( repeaterAdminService: { service }, contactService: { services.contactService }, - nodeSnapshotService: { services.nodeSnapshotService } + nodeSnapshotService: { services.nodeSnapshotService }, + deviceHashSize: { nil } ) await viewModel.cleanup() diff --git a/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift b/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift index 67fe60337..1c54b4077 100644 --- a/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift +++ b/MC1Tests/ViewModels/RepeaterStatusViewModelTests.swift @@ -242,4 +242,53 @@ struct RepeaterStatusViewModelTests { #expect(viewModel.firmwareVersion == nil, "Nodes predating owner-info return an empty firmware string, which must map to nil") } + + // MARK: - Neighbor key display width capture + + @Test + func `Neighbours response captures the key display width from the device hash size`() async { + let viewModel = RepeaterStatusViewModel() + viewModel.configure( + repeaterAdminService: { nil }, + contactService: { nil }, + nodeSnapshotService: { nil }, + deviceHashSize: { 3 } + ) + + #expect(viewModel.neighborKeyDisplayByteCount == NeighborNameResolver.minimumKeyDisplayByteCount, + "Width should start at the floor before any neighbours response") + + await viewModel.handleNeighboursResponse(createNeighboursResponse()) + + #expect(viewModel.neighborKeyDisplayByteCount == 3, "Width should be captured from the device hash size at fetch") + } + + @Test + func `Captured key display width survives a later disconnect`() async { + let viewModel = RepeaterStatusViewModel() + let device = HashSizeStub(value: 3) + viewModel.configure( + repeaterAdminService: { nil }, + contactService: { nil }, + nodeSnapshotService: { nil }, + deviceHashSize: { device.value } + ) + + await viewModel.handleNeighboursResponse(createNeighboursResponse()) + device.value = nil + + #expect(viewModel.neighborKeyDisplayByteCount == 3, + "A disconnect must not reflow identifiers already on screen") + } +} + +/// Mutable hash-size source for provider closures. A captured local var would trip the +/// sendable-closure mutation warning. +@MainActor +private final class HashSizeStub { + var value: Int? + + init(value: Int?) { + self.value = value + } } diff --git a/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift b/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift index 5278baa51..f1df82891 100644 --- a/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift +++ b/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift @@ -71,6 +71,26 @@ struct NeighborSNRMapBuilderTests { NeighbourInfo(publicKeyPrefix: Data(prefix), secondsAgo: 0, snr: snr) } + private func build( + session: RemoteNodeSessionDTO, + neighbors: [NeighbourInfo], + contacts: [ContactDTO], + discoveredNodes: [DiscoveredNodeDTO], + userLocation: CLLocation?, + filter: MapFilterState, + keyDisplayByteCount: Int = NeighborNameResolver.minimumKeyDisplayByteCount + ) -> NeighborSNRMapBuilder.PlottedNeighbors { + NeighborSNRMapBuilder.build( + session: session, + neighbors: neighbors, + contacts: contacts, + discoveredNodes: discoveredNodes, + userLocation: userLocation, + filter: filter, + keyDisplayByteCount: keyDisplayByteCount + ) + } + private let exactPrefix: [UInt8] = [0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6] private let secondExactPrefix: [UInt8] = [0xB1, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6] @@ -99,7 +119,7 @@ struct NeighborSNRMapBuilderTests { ) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -140,7 +160,7 @@ struct NeighborSNRMapBuilderTests { ] let neighbors = [makeNeighbor(prefix: exactPrefix), makeNeighbor(prefix: secondExactPrefix)] - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: neighbors, contacts: contacts, @@ -167,7 +187,7 @@ struct NeighborSNRMapBuilderTests { let contact = makeContact(prefix: exactPrefix, name: "Ridge", latitude: 37.1, longitude: -122.1) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -192,7 +212,7 @@ struct NeighborSNRMapBuilderTests { let node = makeDiscoveredNode(prefix: [0xAB, 0xEF], name: "Advert", latitude: 38.0, longitude: -123.0) let neighbor = makeNeighbor(prefix: [0xAB]) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -213,7 +233,7 @@ struct NeighborSNRMapBuilderTests { let contact = makeContact(prefix: exactPrefix, name: "No GPS", latitude: 0, longitude: 0) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -236,7 +256,7 @@ struct NeighborSNRMapBuilderTests { let node = makeDiscoveredNode(prefix: exactPrefix, name: "Bad GPS", latitude: 200.0, longitude: -122.1) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [], @@ -254,20 +274,71 @@ struct NeighborSNRMapBuilderTests { func `unresolved neighbor falls back to a hex name and is listed`() { let session = makeSession(latitude: 37.0, longitude: -122.0) let neighbor = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + let keyDisplayByteCount = NeighborNameResolver.minimumKeyDisplayByteCount - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [], discoveredNodes: [], userLocation: nil, - filter: MapFilterState() + filter: MapFilterState(), + keyDisplayByteCount: keyDisplayByteCount ) #expect(result.points.filter { $0.pinStyle == .repeater }.isEmpty) #expect(result.unplottable.count == 1) #expect(result.unplottable.first?.matchKind == .unresolved) - #expect(result.unplottable.first?.displayName == NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix)) + #expect( + result.unplottable.first?.displayName + == NeighborNameResolver.fallbackName(for: neighbor.publicKeyPrefix, byteCount: keyDisplayByteCount) + ) + } + + @Test(arguments: [2, 3]) + func `unresolved title hex length matches secondary formatting for the same count`(keyDisplayByteCount: Int) { + let session = makeSession(latitude: 37.0, longitude: -122.0) + let neighbor = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + let expected = NeighborNameResolver.fallbackName( + for: neighbor.publicKeyPrefix, + byteCount: keyDisplayByteCount + ) + + let result = build( + session: session, + neighbors: [neighbor], + contacts: [], + discoveredNodes: [], + userLocation: nil, + filter: MapFilterState(), + keyDisplayByteCount: keyDisplayByteCount + ) + + let unplottable = result.unplottable.first + #expect(unplottable?.matchKind == .unresolved) + #expect(unplottable?.displayName == expected) + #expect(unplottable?.displayName.count == keyDisplayByteCount * 2) + } + + @Test + func `colliding unresolved titles widen to the full prefix`() { + let session = makeSession(latitude: 37.0, longitude: -122.0) + let first = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + let second = makeNeighbor(prefix: [0xDE, 0xAD, 0x01, 0x02, 0x03, 0x04]) + let distinct = makeNeighbor(prefix: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]) + + let result = build( + session: session, + neighbors: [first, second, distinct], + contacts: [], + discoveredNodes: [], + userLocation: nil, + filter: MapFilterState(), + keyDisplayByteCount: NeighborNameResolver.minimumKeyDisplayByteCount + ) + + let titles = result.unplottable.map(\.displayName) + #expect(titles == ["DEADBEEF0001", "DEAD01020304", "AABB"]) } @Test @@ -278,7 +349,7 @@ struct NeighborSNRMapBuilderTests { makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x02]) ] - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: neighbors, contacts: [], @@ -299,7 +370,7 @@ struct NeighborSNRMapBuilderTests { let session = makeSession(latitude: 0, longitude: 0) let neighbor = makeNeighbor(prefix: [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [], @@ -334,7 +405,7 @@ struct NeighborSNRMapBuilderTests { ) let neighbors = [makeNeighbor(prefix: exactPrefix), makeNeighbor(prefix: secondExactPrefix)] - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: neighbors, contacts: [favorite, other], @@ -365,7 +436,7 @@ struct NeighborSNRMapBuilderTests { ) let neighbor = makeNeighbor(prefix: exactPrefix) - let withD = NeighborSNRMapBuilder.build( + let withD = build( session: session, neighbors: [neighbor], contacts: [], @@ -375,7 +446,7 @@ struct NeighborSNRMapBuilderTests { ) #expect(withD.points.count(where: { $0.pinStyle == .repeater }) == 1) - let withoutD = NeighborSNRMapBuilder.build( + let withoutD = build( session: session, neighbors: [neighbor], contacts: [], @@ -394,7 +465,7 @@ struct NeighborSNRMapBuilderTests { let contact = makeContact(prefix: exactPrefix, name: "Ridge", latitude: 37.1, longitude: -122.1) let neighbor = makeNeighbor(prefix: exactPrefix) - let first = NeighborSNRMapBuilder.build( + let first = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -402,7 +473,7 @@ struct NeighborSNRMapBuilderTests { userLocation: nil, filter: MapFilterState() ) - let second = NeighborSNRMapBuilder.build( + let second = build( session: session, neighbors: [neighbor], contacts: [contact], @@ -425,7 +496,7 @@ struct NeighborSNRMapBuilderTests { ) let neighbor = makeNeighbor(prefix: exactPrefix) - let result = NeighborSNRMapBuilder.build( + let result = build( session: session, neighbors: [neighbor], contacts: [], From 8fbc3cec53268482efc3fe98d3794c072ec55c3c Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:38:00 -0700 Subject: [PATCH 03/47] feat(contacts): debounced advert delta sync - Replace per-advert getContact queue with watermarked batch sync - Stamp phone-clock lastHeard for sort, prune, and backup - Claim isSyncInProgress as advert-owned so full sync waits - Admit delta rounds once a full contact fetch completes, so an empty radio with no watermark still syncs its first auto-added contact - Report a busy outcome when another sync holds the claim, so a collision keeps its drained keys instead of spending the failure budget - Cover coalescing, 0x8F rollback, escalation, and prune safety --- MC1/Resources/Generated/L10n.swift | 4 + .../Localization/de.lproj/Contacts.strings | 3 + .../Localization/de.lproj/Map.strings | 3 + .../Localization/en.lproj/Contacts.strings | 3 + .../Localization/en.lproj/Map.strings | 3 + .../Localization/es.lproj/Contacts.strings | 3 + .../Localization/es.lproj/Map.strings | 3 + .../Localization/fr.lproj/Contacts.strings | 3 + .../Localization/fr.lproj/Map.strings | 3 + .../Localization/it.lproj/Contacts.strings | 3 + .../Localization/it.lproj/Map.strings | 3 + .../Localization/nl.lproj/Contacts.strings | 3 + .../Localization/nl.lproj/Map.strings | 3 + .../Localization/pl.lproj/Contacts.strings | 3 + .../Localization/pl.lproj/Map.strings | 3 + .../Localization/ru.lproj/Contacts.strings | 3 + .../Localization/ru.lproj/Map.strings | 3 + .../Localization/uk.lproj/Contacts.strings | 3 + .../Localization/uk.lproj/Map.strings | 3 + .../zh-Hans.lproj/Contacts.strings | 3 + .../Localization/zh-Hans.lproj/Map.strings | 3 + MC1/State/AppState+Wiring.swift | 7 +- .../ChatConversationMessagesContent.swift | 3 +- MC1/Views/Chats/ChatConversationView.swift | 3 +- MC1/Views/Chats/ContactMatchRow.swift | 3 + .../ViewModel/ChatViewModel+Channels.swift | 1 + MC1/Views/Contacts/ContactDetailView.swift | 13 +- MC1/Views/Contacts/ContactRowView.swift | 2 +- MC1/Views/Contacts/ContactsViewModel.swift | 6 +- MC1/Views/Map/ContactCalloutContent.swift | 1 + MC1/Views/Map/ContactDetailSheet.swift | 6 + .../RemoteNodes/NodeAuthenticationSheet.swift | 3 +- .../Tools/LineOfSight/LineOfSightView.swift | 1 + .../ConnectionManager+Pairing.swift | 6 +- .../Sources/MC1Services/Models/Contact.swift | 31 +- .../Persistence/ContactPersisting.swift | 42 +- .../Persistence/MessagePersisting.swift | 12 + .../Services/AdvertisementEvent.swift | 8 + ...vertisementService+ContactFetchQueue.swift | 277 -- .../AdvertisementService+DeltaSync.swift | 495 ++++ .../Services/AdvertisementService.swift | 336 ++- .../MC1Services/Services/ContactService.swift | 36 + .../PersistenceStore+BackupBatchInsert.swift | 3 + .../PersistenceStore+BackupHelpers.swift | 22 + .../Services/PersistenceStore+Contacts.swift | 156 +- .../Services/PersistenceStore+Devices.swift | 6 + .../Services/PersistenceStore+Messages.swift | 21 + .../Simulator/MockDataProvider+Contacts.swift | 8 + .../SyncCoordinator+MessageHandlers.swift | 80 +- .../Sync/SyncCoordinator+Sync.swift | 432 ++- .../MC1Services/Sync/SyncCoordinator.swift | 42 +- .../AdvertisementServiceTests.swift | 2396 +++++++++++++---- .../BackupIntegrationTests.swift | 237 ++ .../BondLossPairingRecoveryTests.swift | 1 + .../ConnectRadioIDResolutionTests.swift | 1 + .../Helpers/ContactDTO+Testing.swift | 2 + .../Mocks/MockMeshCoreSession.swift | 39 + .../Mocks/MockPersistenceStore.swift | 193 +- .../PersistenceStoreTests.swift | 505 +++- .../RepeaterUnreadMigrationTests.swift | 1 + ...hatSendQueueServiceAttemptCountTests.swift | 3 +- .../Services/ChatSendQueueServiceTests.swift | 6 +- .../Services/ContactServiceSyncTests.swift | 194 ++ .../Services/ContactServiceTests.swift | 13 + .../RegionDiscoveryServiceTests.swift | 1 + .../SyncCoordinatorTests.swift | 907 ++++++- .../AppState/NavigationCoordinatorTests.swift | 2 + MC1Tests/AppState/NavigationStateTests.swift | 1 + MC1Tests/Intents/EntityIdentityTests.swift | 2 + MC1Tests/Intents/SendMessageIntentTests.swift | 1 + MC1Tests/Models/ContactOCVTests.swift | 4 + .../Models/ConversationFilteringTests.swift | 1 + .../Services/InlineImagePrefetcherTests.swift | 5 + MC1Tests/Services/LinkPreviewCacheTests.swift | 5 + .../State/ChatPrewarmRefresherTests.swift | 1 + .../State/ChatTimelineFreshnessTests.swift | 1 + MC1Tests/State/ChatTimelinePrimerTests.swift | 1 + .../Utilities/MentionUtilitiesTests.swift | 1 + .../Utilities/RepeaterResolverTests.swift | 1 + .../ViewModels/AppBackupViewModelTests.swift | 1 + .../ChatViewModelPaginationTests.swift | 1 + MC1Tests/ViewModels/ChatViewModelTests.swift | 1 + .../ViewModels/ContactsViewModelTests.swift | 1 + .../LineOfSightViewModelTests.swift | 6 + .../MessagePathViewModelTests.swift | 1 + .../PathManagementViewModelEditingTests.swift | 1 + ...RemoteNodeStatusHandlerSurvivalTests.swift | 1 + MC1Tests/ViewModels/RxLogViewModelTests.swift | 1 + ...lemetryHistoryOverviewViewModelTests.swift | 1 + .../ViewModels/TracePathViewModelTests.swift | 20 +- .../ChannelInfoRegionQueryTargetsTests.swift | 1 + .../Chats/ChatConversationTypeTests.swift | 2 + .../Chats/ChatConversationViewTests.swift | 1 + .../ChatTimelineClobberRegressionTests.swift | 6 +- MC1Tests/Views/Chats/ChatTimelineTests.swift | 1 + .../Chats/ChatViewModelAdmissionTests.swift | 5 + .../ChatViewModelConversationTests.swift | 1 + .../ChatViewModelDeleteSequencingTests.swift | 1 + .../ChatViewModelReactionIndexingTests.swift | 1 + ...hatViewModelReloadSerializationTests.swift | 1 + .../Views/Chats/ContactMatchRowTests.swift | 40 + ...nversationListScrollPerfHarnessTests.swift | 1 + .../MessageBubbleConfigurationTests.swift | 1 + .../Chats/SenderContactMatcherTests.swift | 1 + .../ContactsViewModelDeleteTests.swift | 1 + .../Map/MapViewModelDiscoveredTests.swift | 1 + MC1Tests/Views/Map/MapViewModelTests.swift | 1 + .../Views/Map/TracePathMapFilterTests.swift | 3 + .../NeighborSNRMapBuilderTests.swift | 1 + .../Tools/CLI/CLIToolViewModelTests.swift | 5 + 110 files changed, 5869 insertions(+), 892 deletions(-) delete mode 100644 MC1Services/Sources/MC1Services/Services/AdvertisementService+ContactFetchQueue.swift create mode 100644 MC1Services/Sources/MC1Services/Services/AdvertisementService+DeltaSync.swift create mode 100644 MC1Tests/Views/Chats/ContactMatchRowTests.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 034a7fae7..d3195ebd9 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1225,6 +1225,8 @@ public enum L10n { public static let joinRoom = L10n.tr("Contacts", "contacts.detail.joinRoom", fallback: "Join Room") /// Location: ContactDetailView.swift - Purpose: Last advert label public static let lastAdvert = L10n.tr("Contacts", "contacts.detail.lastAdvert", fallback: "Last Advert") + /// Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label + public static let lastHeard = L10n.tr("Contacts", "contacts.detail.lastHeard", fallback: "Last Heard") /// Location: ContactDetailView.swift - Purpose: Location section header public static let location = L10n.tr("Contacts", "contacts.detail.location", fallback: "Location") /// Location: ContactDetailView.swift - Purpose: Management button @@ -2725,6 +2727,8 @@ public enum L10n { public static let hopSingular = L10n.tr("Map", "map.detail.hopSingular", fallback: "1 hop") /// Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp public static let lastAdvert = L10n.tr("Map", "map.detail.lastAdvert", fallback: "Last Advert") + /// Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label + public static let lastHeard = L10n.tr("Map", "map.detail.lastHeard", fallback: "Last Heard") /// Location: MapView.swift ContactDetailSheet - Purpose: Label for latitude coordinate public static let latitude = L10n.tr("Map", "map.detail.latitude", fallback: "Latitude") /// Location: MapView.swift ContactDetailSheet - Purpose: Label for longitude coordinate diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index 78180cd89..a3d1d832e 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Letzte Ankündigung"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Zuletzt gehört"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Ungelesene Nachrichten"; diff --git a/MC1/Resources/Localization/de.lproj/Map.strings b/MC1/Resources/Localization/de.lproj/Map.strings index 91405b2df..984b471c1 100644 --- a/MC1/Resources/Localization/de.lproj/Map.strings +++ b/MC1/Resources/Localization/de.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Letzte Ankündigung"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Zuletzt gehört"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Öffentlicher Schlüssel"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index a8cbd4b89..795c09d4e 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Last Advert"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Last Heard"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Unread Messages"; diff --git a/MC1/Resources/Localization/en.lproj/Map.strings b/MC1/Resources/Localization/en.lproj/Map.strings index ac3a89d22..075d3f30d 100644 --- a/MC1/Resources/Localization/en.lproj/Map.strings +++ b/MC1/Resources/Localization/en.lproj/Map.strings @@ -77,6 +77,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Last Advert"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Last Heard"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Public Key"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index 003cc20bb..f9ce4d30c 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Último anuncio"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Última escucha"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Mensajes sin leer"; diff --git a/MC1/Resources/Localization/es.lproj/Map.strings b/MC1/Resources/Localization/es.lproj/Map.strings index 829d64910..83b915275 100644 --- a/MC1/Resources/Localization/es.lproj/Map.strings +++ b/MC1/Resources/Localization/es.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Último anuncio"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Última escucha"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Clave pública"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index 3056a8fc4..693a557d5 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Dernière annonce"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Dernière écoute"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Messages non lus"; diff --git a/MC1/Resources/Localization/fr.lproj/Map.strings b/MC1/Resources/Localization/fr.lproj/Map.strings index 74abad884..b3e7526ef 100644 --- a/MC1/Resources/Localization/fr.lproj/Map.strings +++ b/MC1/Resources/Localization/fr.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Dernière annonce"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Dernière écoute"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Clé publique"; diff --git a/MC1/Resources/Localization/it.lproj/Contacts.strings b/MC1/Resources/Localization/it.lproj/Contacts.strings index c4bee8c36..efd50f55d 100644 --- a/MC1/Resources/Localization/it.lproj/Contacts.strings +++ b/MC1/Resources/Localization/it.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Ultimo annuncio"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Ultimo ascolto"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Messaggi non letti"; diff --git a/MC1/Resources/Localization/it.lproj/Map.strings b/MC1/Resources/Localization/it.lproj/Map.strings index d79cdadf8..c32a10295 100644 --- a/MC1/Resources/Localization/it.lproj/Map.strings +++ b/MC1/Resources/Localization/it.lproj/Map.strings @@ -77,6 +77,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Ultimo annuncio"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Ultimo ascolto"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Chiave pubblica"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index d3c6f7346..102c676aa 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Laatste advertentie"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Laatst gehoord"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Ongelezen berichten"; diff --git a/MC1/Resources/Localization/nl.lproj/Map.strings b/MC1/Resources/Localization/nl.lproj/Map.strings index af458abfb..6f069090f 100644 --- a/MC1/Resources/Localization/nl.lproj/Map.strings +++ b/MC1/Resources/Localization/nl.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Laatste advertentie"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Laatst gehoord"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Publieke sleutel"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 106f6e6ca..04a876876 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Ostatnie ogłoszenie"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Ostatnio usłyszany"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Nieprzeczytane wiadomości"; diff --git a/MC1/Resources/Localization/pl.lproj/Map.strings b/MC1/Resources/Localization/pl.lproj/Map.strings index 136afdf3d..3b39b401c 100644 --- a/MC1/Resources/Localization/pl.lproj/Map.strings +++ b/MC1/Resources/Localization/pl.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Ostatnie ogłoszenie"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Ostatnio usłyszany"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Klucz publiczny"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index c50059365..d3ba5ef49 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Последнее объявление"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Последний приём"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Непрочитанные сообщения"; diff --git a/MC1/Resources/Localization/ru.lproj/Map.strings b/MC1/Resources/Localization/ru.lproj/Map.strings index 690db214e..bb24c1c83 100644 --- a/MC1/Resources/Localization/ru.lproj/Map.strings +++ b/MC1/Resources/Localization/ru.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Последний сигнал"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Последний приём"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Публичный ключ"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index eb186b868..b9b309f8a 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "Останнє оголошення"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Останнє отримання"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "Непрочитані повідомлення"; diff --git a/MC1/Resources/Localization/uk.lproj/Map.strings b/MC1/Resources/Localization/uk.lproj/Map.strings index 29f46ff90..b34dcad2b 100644 --- a/MC1/Resources/Localization/uk.lproj/Map.strings +++ b/MC1/Resources/Localization/uk.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "Останнє оголошення"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Останнє отримання"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "Публічний ключ"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index d208d5754..318a40207 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -299,6 +299,9 @@ /* Location: ContactDetailView.swift - Purpose: Last advert label */ "contacts.detail.lastAdvert" = "最近广播"; +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "最后收到"; + /* Location: ContactDetailView.swift - Purpose: Unread messages label */ "contacts.detail.unreadMessages" = "未读消息"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings index c92aa6a59..4550d49bd 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Map.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Map.strings @@ -79,6 +79,9 @@ /* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ "map.detail.lastAdvert" = "最后广播"; +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "最后收到"; + /* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ "map.detail.publicKey" = "公钥"; diff --git a/MC1/State/AppState+Wiring.swift b/MC1/State/AppState+Wiring.swift index 01bc5adde..eea68fe5f 100644 --- a/MC1/State/AppState+Wiring.swift +++ b/MC1/State/AppState+Wiring.swift @@ -92,12 +92,17 @@ extension AppState { contactsVersion += 1 PersistentLogger(subsystem: "com.mc1", category: "discover-trace") .info("B4 contactUpdated bump contactsVersion=\(contactsVersion)") + case .conversationsChanged: + refreshConversations() 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, .nodeStorageFullChanged, - .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: + .pathDiscoveryResponse, .traceResponse, .traceSnrObserved, + .orphanDirectMessagesAdopted: + // The paired .conversationsChanged already refreshes the list; the + // banner and badge are handled by the SyncCoordinator consumer. break } } diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index afed2e37b..9a857a6d5 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -233,7 +233,8 @@ private struct ChannelEmptyMessagesView: View { conversationType: .dm(ContactDTO(from: Contact( radioID: UUID(), publicKey: Data(repeating: 0x42, count: 32), - name: "Alice" + name: "Alice", + lastHeardTimestamp: 0 ))), viewModel: ChatViewModel(), deviceName: "My Device", diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index eb3eac042..ae5cf9a93 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -772,7 +772,8 @@ private extension View { conversationType: .dm(ContactDTO(from: Contact( radioID: UUID(), publicKey: Data(repeating: 0x42, count: 32), - name: "Alice" + name: "Alice", + lastHeardTimestamp: 0 ))) ) } diff --git a/MC1/Views/Chats/ContactMatchRow.swift b/MC1/Views/Chats/ContactMatchRow.swift index b04a12a75..c77abfab7 100644 --- a/MC1/Views/Chats/ContactMatchRow.swift +++ b/MC1/Views/Chats/ContactMatchRow.swift @@ -27,6 +27,9 @@ struct ContactMatchRow: View { .bold() .foregroundStyle(.primary) + // On-air last advert only. `recencyTimestamp` includes lastModified + // (path updates, favorite toggles) and would claim a freshness the + // node did not earn when the user is picking among name matches. RelativeTimestampText(timestamp: contact.lastAdvertTimestamp) HStack(spacing: 4) { diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift index ead76686f..72e68cf4f 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift @@ -309,6 +309,7 @@ extension ChatViewModel { latitude: 0.0, longitude: 0.0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 72a672789..59d67c6f1 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -938,7 +938,16 @@ private struct ContactInfoSection: View { .foregroundStyle(.secondary) } - // Last advert + // Phone-clock last heard (on-air) + if let lastHeard = currentContact.lastHeardTimestamp, lastHeard > 0 { + HStack { + Text(L10n.Contacts.Contacts.Detail.lastHeard) + Spacer() + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(lastHeard)), font: .body) + } + } + + // Radio-sourced last advert if currentContact.lastAdvertTimestamp > 0 { HStack { Text(L10n.Contacts.Contacts.Detail.lastAdvert) @@ -1357,6 +1366,7 @@ private struct ContactDangerSection: View { name: "Alice", latitude: 37.7749, longitude: -122.4194, + lastHeardTimestamp: 0, isFavorite: true ))) } @@ -1372,6 +1382,7 @@ private struct ContactDangerSection: View { name: "Alice", latitude: 37.7749, longitude: -122.4194, + lastHeardTimestamp: 0, isFavorite: true )), showFromDirectChat: true diff --git a/MC1/Views/Contacts/ContactRowView.swift b/MC1/Views/Contacts/ContactRowView.swift index ffc628cc0..01deb9074 100644 --- a/MC1/Views/Contacts/ContactRowView.swift +++ b/MC1/Views/Contacts/ContactRowView.swift @@ -57,7 +57,7 @@ struct ContactRowView: View { .accessibilityLabel(L10n.Contacts.Contacts.Row.favorite) } - RelativeTimestampText(timestamp: contact.lastModified) + RelativeTimestampText(timestamp: contact.recencyTimestamp) } HStack(spacing: 8) { diff --git a/MC1/Views/Contacts/ContactsViewModel.swift b/MC1/Views/Contacts/ContactsViewModel.swift index bce45dcc8..60b18e011 100644 --- a/MC1/Views/Contacts/ContactsViewModel.swift +++ b/MC1/Views/Contacts/ContactsViewModel.swift @@ -158,6 +158,8 @@ final class ContactsViewModel { syncProgress = nil errorMessage = nil + // `setSyncingContacts(true)` then `syncContactsForRefresh` so an advert-driven + // sync cannot interleave progress events with this refresh. if let advertisementService { await advertisementService.setSyncingContacts(true) } @@ -175,7 +177,7 @@ final class ContactsViewModel { defer { progressTask.cancel() } do { - _ = try await contactService.syncContacts(radioID: radioID) + _ = try await contactService.syncContactsForRefresh(radioID: radioID) // Reload from database await loadContacts(radioID: radioID) @@ -350,7 +352,7 @@ final class ContactsViewModel { ) -> [ContactDTO] { switch order { case .lastHeard: - contacts.sorted { $0.lastModified > $1.lastModified } + contacts.sorted { $0.recencyTimestamp > $1.recencyTimestamp } case .name: contacts.sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending diff --git a/MC1/Views/Map/ContactCalloutContent.swift b/MC1/Views/Map/ContactCalloutContent.swift index 68cd3150f..3e50b0592 100644 --- a/MC1/Views/Map/ContactCalloutContent.swift +++ b/MC1/Views/Map/ContactCalloutContent.swift @@ -83,6 +83,7 @@ struct ContactCalloutContent: View { typeRawValue: 0, latitude: 37.7749, longitude: -122.4194, + lastHeardTimestamp: 0, isFavorite: true ) ), diff --git a/MC1/Views/Map/ContactDetailSheet.swift b/MC1/Views/Map/ContactDetailSheet.swift index af5dd33d9..941c20f2d 100644 --- a/MC1/Views/Map/ContactDetailSheet.swift +++ b/MC1/Views/Map/ContactDetailSheet.swift @@ -77,6 +77,12 @@ struct ContactDetailSheet: View { } } + if let lastHeard = contact.lastHeardTimestamp, lastHeard > 0 { + LabeledContent(L10n.Map.Map.Detail.lastHeard) { + ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(lastHeard)), font: .body) + } + } + if contact.lastAdvertTimestamp > 0 { LabeledContent(L10n.Map.Map.Detail.lastAdvert) { ConversationTimestamp(date: Date(timeIntervalSince1970: TimeInterval(contact.lastAdvertTimestamp)), font: .body) diff --git a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift index 98ce67a05..6696c7061 100644 --- a/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift +++ b/MC1/Views/RemoteNodes/NodeAuthenticationSheet.swift @@ -499,7 +499,8 @@ private struct ConnectButton: View { radioID: UUID(), publicKey: Data(repeating: 0x42, count: 32), name: "Test Room", - typeRawValue: ContactType.room.rawValue + typeRawValue: ContactType.room.rawValue, + lastHeardTimestamp: 0 )), role: .roomServer, onSuccess: { _ in } diff --git a/MC1/Views/Tools/LineOfSight/LineOfSightView.swift b/MC1/Views/Tools/LineOfSight/LineOfSightView.swift index e49878eec..1010e2f3a 100644 --- a/MC1/Views/Tools/LineOfSight/LineOfSightView.swift +++ b/MC1/Views/Tools/LineOfSight/LineOfSightView.swift @@ -614,6 +614,7 @@ private struct AnalyzeButton: View { latitude: 37.7749, longitude: -122.4194, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift index 6f321748a..108d8077f 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift @@ -284,14 +284,14 @@ public extension ConnectionManager { try await removeContacts(matching: { !$0.isFavorite }) } - /// Removes non-favorite contacts whose `lastModified` timestamp is older than the given threshold. + /// Removes non-favorite contacts whose recency 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 + return try await removeContacts(matching: { $0.matchesStaleNodePrune(cutoff: cutoff) }) { contact in + let ageDays = (Int(Date().timeIntervalSince1970) - Int(contact.recencyTimestamp)) / 86400 let keyPrefix = contact.publicKeyHex.prefix(8) self.logger.info("Auto-removed stale node '\(contact.name)' [\(keyPrefix)] (last heard \(ageDays)d ago)") } diff --git a/MC1Services/Sources/MC1Services/Models/Contact.swift b/MC1Services/Sources/MC1Services/Models/Contact.swift index 27c20b381..5ca958417 100644 --- a/MC1Services/Sources/MC1Services/Models/Contact.swift +++ b/MC1Services/Sources/MC1Services/Models/Contact.swift @@ -50,6 +50,10 @@ public final class Contact { /// Last modification timestamp (for sync watermarking) public var lastModified: UInt32 + /// Phone-clock epoch seconds of the last on-air evidence this phone heard + /// for the contact. Monotonic; 0 means never heard by this phone. + public var lastHeardTimestamp: UInt32 = 0 + /// Local nickname override (optional) public var nickname: String? @@ -93,6 +97,7 @@ public final class Contact { latitude: Double = 0, longitude: Double = 0, lastModified: UInt32 = 0, + lastHeardTimestamp: UInt32, nickname: String? = nil, isBlocked: Bool = false, isMuted: Bool = false, @@ -116,6 +121,7 @@ public final class Contact { self.latitude = latitude self.longitude = longitude self.lastModified = lastModified + self.lastHeardTimestamp = lastHeardTimestamp self.nickname = nickname self.isBlocked = isBlocked self.isMuted = isMuted @@ -144,6 +150,7 @@ public final class Contact { latitude: dto.latitude, longitude: dto.longitude, lastModified: dto.lastModified, + lastHeardTimestamp: dto.lastHeardTimestamp ?? 0, nickname: dto.nickname, isBlocked: dto.isBlocked, isMuted: dto.isMuted, @@ -172,6 +179,7 @@ public final class Contact { latitude = dto.latitude longitude = dto.longitude lastModified = dto.lastModified + lastHeardTimestamp = max(lastHeardTimestamp, dto.lastHeardTimestamp ?? 0) nickname = dto.nickname isBlocked = dto.isBlocked isMuted = dto.isMuted @@ -198,6 +206,7 @@ public final class Contact { latitude: frame.latitude, longitude: frame.longitude, lastModified: frame.lastModified, + lastHeardTimestamp: 0, isFavorite: (frame.flags & 0x01) != 0 ) } @@ -293,6 +302,8 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, public let latitude: Double public let longitude: Double public let lastModified: UInt32 + /// Phone-clock epoch seconds; nil in legacy backup envelopes means never heard. + public let lastHeardTimestamp: UInt32? public let nickname: String? public let isBlocked: Bool public let isMuted: Bool @@ -317,6 +328,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, latitude = contact.latitude longitude = contact.longitude lastModified = contact.lastModified + lastHeardTimestamp = contact.lastHeardTimestamp nickname = contact.nickname isBlocked = contact.isBlocked isMuted = contact.isMuted @@ -343,6 +355,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, latitude: Double, longitude: Double, lastModified: UInt32, + lastHeardTimestamp: UInt32?, nickname: String?, isBlocked: Bool, isMuted: Bool, @@ -366,6 +379,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, self.latitude = latitude self.longitude = longitude self.lastModified = lastModified + self.lastHeardTimestamp = lastHeardTimestamp self.nickname = nickname self.isBlocked = isBlocked self.isMuted = isMuted @@ -451,6 +465,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, isFavorite: isFavorite, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, @@ -466,6 +481,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, isFavorite: isFavorite, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, @@ -481,6 +497,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, typeRawValue: typeRawValue, flags: flags, outPathLength: outPathLength, outPath: outPath, lastAdvertTimestamp: lastAdvertTimestamp, latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, isFavorite: isFavorite, lastMessageDate: lastMessageDate, unreadCount: unreadCount, unreadMentionCount: unreadMentionCount, @@ -511,8 +528,20 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, // MARK: - RepeaterResolvable + /// Max of radio `lastModified` and phone-clock `lastHeardTimestamp`. + /// Legacy rows with a nil/0 heard stamp keep `lastModified` behavior. + public var recencyTimestamp: UInt32 { + max(lastModified, lastHeardTimestamp ?? 0) + } + + /// Match used by `ConnectionManager.removeStaleNodes` for the given cutoff + /// (epoch seconds). Favorites never match. + public func matchesStaleNodePrune(cutoff: UInt32) -> Bool { + !isFavorite && recencyTimestamp < cutoff + } + public var recencyDate: Date { - Date(timeIntervalSince1970: Double(lastModified)) + Date(timeIntervalSince1970: Double(recencyTimestamp)) } public var resolvableName: String { diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift index 1e2ca24e1..f4297806a 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/ContactPersisting.swift @@ -53,12 +53,36 @@ public protocol ContactPersisting: Actor { func deleteContact(id: UUID) async throws /// Deletes the contact only when no messages reference it. Insert-only - /// rollback of cancelled advert fetches: prefer an orphan contact over - /// cascade-wiping a concurrent DM. Implementations that share a ModelActor - /// with message storage must keep probe and delete in one isolation region - /// with no suspension between them. + /// rollback of a mid-sync radio delete: prefer an orphan contact over a + /// cascade that wipes a concurrent DM. Implementations that share a + /// ModelActor with message storage must keep probe and delete in one + /// isolation region with no suspension between them. + /// + /// PendingSend and Reaction cascade with their Message rows on + /// `deleteContact`, so a Message probe is sufficient. func deleteContactIfUnreferenced(id: UUID) async throws + /// Links direct messages stored before their contact row existed. Channel + /// rows are excluded by their non-nil channelIndex; the sender-key prefix + /// match runs in memory because `#Predicate` cannot express `Data.prefix`. + /// A prefix matching two contacts is left orphaned rather than guessed. + /// + /// Once a DM is adopted, `deleteContactIfUnreferenced` cannot roll that + /// contact back, and an incremental sync never prunes it. Ghost contacts + /// become permanent. That is the intended trade. + /// + /// Returns the number of messages adopted per contact id. Does not update + /// the springboard badge; the contact unread count is updated in place. + func adoptOrphanedDirectMessages( + radioID: UUID, + contacts: [(id: UUID, publicKey: Data)] + ) async throws -> [UUID: Int] + + /// Stamps phone-clock recency for a contact heard on air and the matching + /// DiscoveredNode lastHeard, creating that Discover row from stored radio + /// fields when it is missing. Returns true when a Contact row existed. + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool + /// Update contact's last message info (nil clears the date, removing from conversations list) func updateContactLastMessage(contactID: UUID, date: Date?) async throws @@ -135,4 +159,14 @@ public extension ContactPersisting where Self: MessagePersisting { guard messages.isEmpty else { return } try await deleteContact(id: id) } + + /// Default no-op for lightweight stubs. Concrete stores override; the method + /// must be `async throws` on the store so overload resolution does not pick + /// this empty default over the real implementation. + func adoptOrphanedDirectMessages( + radioID: UUID, + contacts: [(id: UUID, publicKey: Data)] + ) async throws -> [UUID: Int] { + [:] + } } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift index a1d7e1f2e..79dbbf404 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift @@ -21,6 +21,11 @@ public protocol MessagePersisting: Actor { /// Fetch messages for a contact func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] + /// Newest unread incoming message for a contact, or nil when none. + /// Used to post a direct-message banner after orphan DMs are adopted to a + /// contact that did not exist when they arrived. + func newestUnreadIncomingMessage(contactID: UUID) async throws -> MessageDTO? + /// Fetch messages for a channel func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] @@ -160,4 +165,11 @@ extension MessagePersisting { func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus) async throws { try await updateMessageAck(id: id, ackCode: ackCode, status: status, roundTripTime: nil) } + + /// Default no-op for lightweight stubs. Concrete stores override; the method + /// must be `async throws` on the store so overload resolution does not pick + /// this empty default over the real implementation. + func newestUnreadIncomingMessage(contactID: UUID) async throws -> MessageDTO? { + nil + } } diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift index ac6225bdf..7e4a18719 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementEvent.swift @@ -12,6 +12,14 @@ public enum AdvertisementEvent: Sendable { /// A contact or discovered node was created or updated; observers should /// reload contact lists. case contactUpdated + /// Adopted orphan DMs created or updated a conversation row; observers must + /// refresh the conversation list (mirrors `SyncDataEvent.conversationsChanged`). + case conversationsChanged + /// Orphan DMs were linked to these contacts by a delta round. A DM that + /// arrived before its sender's contact existed had no contact to notify at + /// receipt; the owner of `NotificationService` posts the banner and refreshes + /// the badge for each contact when adoption resolves the sender. + case orphanDirectMessagesAdopted(contactIDs: [UUID]) /// A new contact was discovered via advertisement. case newContactDiscovered(name: String, contactID: UUID, contactType: ContactType) /// The device's node storage full state changed (true = full, false = has space). diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService+ContactFetchQueue.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService+ContactFetchQueue.swift deleted file mode 100644 index b08541f08..000000000 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementService+ContactFetchQueue.swift +++ /dev/null @@ -1,277 +0,0 @@ -import Foundation -import MeshCore - -// MARK: - Contact Fetch Queue - -extension AdvertisementService { - /// Why a contact fetch was requested; determines post-fetch persistence and events. - enum ContactFetchReason { - case advert - case pathUpdate - } - - struct ContactFetchEntry { - var reason: ContactFetchReason - /// Captured at enqueue so teardown (which nils currentRadioID) can't misattribute work. - var radioID: UUID - } - - func enqueueContactFetch(_ publicKey: Data, reason: ContactFetchReason, radioID: UUID) { - if var existing = contactFetchQueue[publicKey] { - // Advert post-fetch work is a superset of pathUpdate's. - if reason == .advert { - existing.reason = .advert - } - existing.radioID = radioID - contactFetchQueue[publicKey] = existing - } else { - contactFetchQueue[publicKey] = ContactFetchEntry(reason: reason, radioID: radioID) - } - startContactFetchWorkerIfNeeded() - } - - func startContactFetchWorkerIfNeeded() { - guard contactFetchWorker == nil else { return } - guard !isSyncingContacts else { return } - guard !contactFetchQueue.isEmpty else { return } - - contactFetchWorkerGeneration &+= 1 - let generation = contactFetchWorkerGeneration - contactFetchWorker = Task { [weak self] in - await self?.runContactFetchWorker(generation: generation) - } - } - - func runContactFetchWorker(generation: UInt64) async { - // Each iteration is one snapshotted pass. Barrier waiters resume at pass - // boundaries so late joiners never await drain-until-empty under live traffic. - while !Task.isCancelled, !isSyncingContacts { - let passKeys = Set(contactFetchQueue.keys) - // Emptiness check and generation-matched nil share one actor-isolated - // region with no await between them (lost-wakeup handshake). - if passKeys.isEmpty { - break - } - - for publicKey in passKeys { - guard !Task.isCancelled else { break } - guard !isSyncingContacts else { break } - // 0x8F may have removed this entry after the pass was snapshotted. - guard contactFetchQueue[publicKey] != nil else { continue } - - await processContactFetch(publicKey: publicKey) - } - - // Always resume waiters at the pass boundary, including after throws. - resumeBarrierWaiters() - } - - // Only the active worker may clear the ref (generation match). - if contactFetchWorkerGeneration == generation { - contactFetchWorker = nil - } - resumeBarrierWaiters() - } - - /// True when 0x8F/teardown cancelled the airborne fetch, or the worker Task was cancelled. - var isCommitCancelled: Bool { - Task.isCancelled || inFlightCancelled - } - - /// Removes the queue entry only when this fetch was not cancelled mid-flight. - /// A 0x8F + re-enqueue may own the slot; wiping it would strand the newer request. - func removeQueueEntryUnlessSuperseded(_ publicKey: Data) { - if !inFlightCancelled { - contactFetchQueue.removeValue(forKey: publicKey) - } - } - - func processContactFetch(publicKey: Data) async { - let pubKeyHex = publicKey.uppercaseHexString() - - inFlightKey = publicKey - inFlightCancelled = false - defer { - if inFlightKey == publicKey { - inFlightKey = nil - } - } - - let meshContact: MeshContact? - do { - meshContact = try await session.getContact(publicKey: publicKey) - } catch { - logger.error("Failed to fetch contact: \(error.localizedDescription)") - discoverTrace.error("B2 getContact THREW key=\(pubKeyHex): \(error.localizedDescription)") - // Drop unless a re-enqueue already owns the slot; later adverts re-enqueue. - removeQueueEntryUnlessSuperseded(publicKey) - return - } - - if isCommitCancelled { - // Task cancel without re-enqueue can leave a stale entry; stop clears the queue. - removeQueueEntryUnlessSuperseded(publicKey) - return - } - - guard let meshContact else { - discoverTrace.notice("B2 getContact returned nil key=\(pubKeyHex)") - removeQueueEntryUnlessSuperseded(publicKey) - return - } - - // Re-read under the actor: advert may have upgraded a pathUpdate entry mid-flight. - guard let entry = contactFetchQueue[publicKey] else { - return - } - let reason = entry.reason - let radioID = entry.radioID - - let frame = meshContact.toContactFrame() - - switch reason { - case .advert: - await commitAdvertFetch( - publicKey: publicKey, - pubKeyHex: pubKeyHex, - meshContact: meshContact, - frame: frame, - radioID: radioID - ) - case .pathUpdate: - await commitPathUpdateFetch( - publicKey: publicKey, - pubKeyHex: pubKeyHex, - meshContact: meshContact, - frame: frame, - radioID: radioID - ) - // Advert may have upgraded the queue entry while pathUpdate save was airborne. - if !isCommitCancelled, - let upgraded = contactFetchQueue[publicKey], - upgraded.reason == .advert { - await commitAdvertFetch( - publicKey: publicKey, - pubKeyHex: pubKeyHex, - meshContact: meshContact, - frame: frame, - radioID: upgraded.radioID - ) - } - } - - // After cancel, any remaining entry belongs to a newer enqueue. - if !isCommitCancelled { - contactFetchQueue.removeValue(forKey: publicKey) - } - } - - func commitAdvertFetch( - publicKey: Data, - pubKeyHex: String, - meshContact: MeshContact, - frame: ContactFrame, - radioID: UUID - ) async { - if isCommitCancelled { return } - - do { - let saveResult = try await dataStore.saveContact(radioID: radioID, from: frame) - if isCommitCancelled { - await rollbackInsertIfNeeded(saveResult) - return - } - - do { - let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) - if isCommitCancelled { - await rollbackInsertIfNeeded(saveResult) - return - } - discoverTrace.info("B2 0x80 getContact OK upsert key=\(pubKeyHex) isNew=\(isNew)") - } catch { - discoverTrace.error("B2 0x80 getContact-path upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") - if isCommitCancelled { - await rollbackInsertIfNeeded(saveResult) - return - } - } - - // Empty names pass through; NotificationService supplies a localized fallback. - let contactName = meshContact.advertisedName - let contactType = meshContact.type - if saveResult.isNew { - eventBroadcaster.yield(.newContactDiscovered( - name: contactName, - contactID: saveResult.id, - contactType: contactType - )) - logOverwriteReplacementIfRecent( - newContactName: contactName.isEmpty ? "Unknown Contact" : contactName, - newContactType: contactType - ) - } else { - // Already-synced row (deferred or repeat fetch) — UI refresh only. - eventBroadcaster.yield(.contactUpdated) - } - } catch { - logger.error("Failed to save fetched contact: \(error.localizedDescription)") - discoverTrace.error("B2 0x80 saveContact FAILED key=\(pubKeyHex): \(error.localizedDescription)") - } - } - - func commitPathUpdateFetch( - publicKey: Data, - pubKeyHex: String, - meshContact: MeshContact, - frame: ContactFrame, - radioID: UUID - ) async { - if isCommitCancelled { return } - - do { - let saveResult = try await dataStore.saveContact(radioID: radioID, from: frame) - if isCommitCancelled { - // Insert-only rollback; never cascade-delete messages on an existing upsert. - await rollbackInsertIfNeeded(saveResult) - return - } - - logger.debug("Refreshed contact path: \(meshContact.advertisedName.isEmpty ? "unnamed" : meshContact.advertisedName)") - eventBroadcaster.yield(.contactUpdated) - } catch { - logger.error("Error refreshing contact path: \(error.localizedDescription)") - } - } - - /// Rolls back only when this fetch inserted the row. Routes through - /// `deleteContactIfUnreferenced` so probe and delete share one store - /// isolation region and probe errors fail closed. - func rollbackInsertIfNeeded(_ saveResult: (id: UUID, isNew: Bool)) async { - guard saveResult.isNew else { return } - do { - try await dataStore.deleteContactIfUnreferenced(id: saveResult.id) - } catch { - logger.warning("Insert-only rollback failed for \(saveResult.id): \(error.localizedDescription)") - } - } - - func resumeBarrierWaiters() { - let waiters = barrierWaiters - barrierWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } - } - - func cancelContactFetchWorkerAndClearQueue() { - contactFetchWorker?.cancel() - // Bump generation so a late worker exit cannot nil a restarted worker. - contactFetchWorkerGeneration &+= 1 - contactFetchWorker = nil - contactFetchQueue.removeAll() - inFlightCancelled = true - inFlightKey = nil - resumeBarrierWaiters() - } -} diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService+DeltaSync.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService+DeltaSync.swift new file mode 100644 index 000000000..2f7a132dd --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementService+DeltaSync.swift @@ -0,0 +1,495 @@ +import Foundation +import MeshCore + +// MARK: - Debounced Contact Delta Sync + +extension AdvertisementService { + func scheduleDeltaSync() { + guard deltaSyncTask == nil else { return } + deltaSyncGeneration &+= 1 + let generation = deltaSyncGeneration + var fireAt = ContinuousClock.now + advertSyncDebounce + if let lastDeltaSyncEnd { + fireAt = max(fireAt, lastDeltaSyncEnd + advertSyncMinInterval) + } + deltaSyncTask = Task { [weak self] in + await self?.runDeltaSync(fireAt: fireAt, generation: generation) + } + } + + /// Clears `deltaSyncTask` only when this round still owns the registration. + /// A cancelled round that resumes after a re-arm must not wipe the new task. + func finishRound(generation: UInt64) { + guard deltaSyncGeneration == generation else { return } + deltaSyncTask = nil + } + + func runDeltaSync(fireAt: ContinuousClock.Instant, generation: UInt64) async { + do { + try await Task.sleep(until: fireAt, clock: .continuous) + } catch { + finishRound(generation: generation) + return + } + + guard !Task.isCancelled, let handler = deltaSyncHandler else { + finishRound(generation: generation) + return + } + guard !isSyncingContacts else { + finishRound(generation: generation) + return + } + + // Empty-round guard before drain so a schedule with no work never clears a + // pending 0x8F set or spends a radio round. Must run before + // `contactsDeletedDuringSync.removeAll()`. + guard hasPendingDeltaSyncWork else { + finishRound(generation: generation) + return + } + + // Capture before teardown clears `currentRadioID` so rollback can still run + // after `stopEventMonitoring`. + let radioID = currentRadioID + + let drained = pendingAdvertKeys + pendingAdvertKeys.removeAll() + let drainedReceiveTimes = takeAdvertReceiveTimes(for: drained) + let drainedPathKeys = pendingPathKeys + pendingPathKeys.removeAll() + let fullRefetch = escalateToFullRefetch + escalateToFullRefetch = false + // Clear before the commit so only mid-round deletes race this round's write path. + contactsDeletedDuringSync.removeAll() + + // Pre-round key set gates .newContactDiscovered and escalation only. + // A failed read must not invent notifications (prefer miss over false + // notify). Adoption is separate: it uses every drained key that has a row + // after the exchange so a snapshot failure cannot leave DMs orphaned. + let preRoundKnownKeys: Set? + if let radioID { + do { + preRoundKnownKeys = try await dataStore.fetchContactPublicKeys(radioID: radioID) + } catch { + logger.error( + "Contact key snapshot failed; suppressing new-contact notifications this round: \(error.localizedDescription)" + ) + preRoundKnownKeys = nil + } + } else { + preRoundKnownKeys = nil + } + let unknownAtStart: Set = if let preRoundKnownKeys { + drained.subtracting(preRoundKnownKeys) + } else { + [] + } + + // Snapshot lastModified for known path keys so a successful incremental + // that returns nothing (radio lastmod ≤ watermark) is detectable. + let preRoundPathLastModified = await snapshotPathLastModified( + keys: drainedPathKeys, radioID: radioID + ) + + let outcome = await handler(fullRefetch) + + // Rollback before the teardown guard: a failed or cancelled round may already + // have committed early batches, and an incremental sync never prunes them. + await rollBackContactsDeletedDuringSync(radioID: radioID) + + guard !Task.isCancelled, deltaSyncHandler != nil else { + finishRound(generation: generation) + return + } + + switch outcome { + case .busy: + // The radio was never asked, so this round keeps the failure budget intact. + // Do not stamp lastDeltaSyncEnd: a busy poll must not inherit the full min + // interval. A short busy-specific backoff re-arms below. + logger.info("Advert delta sync deferred: another sync holds the claim") + finishRound(generation: generation) + requeueDrainedWork( + drained, pathKeys: drainedPathKeys, fullRefetch: fullRefetch, shouldSchedule: false + ) + scheduleBusyRetry() + return + + case .notReady: + // Permanent until a full contact fetch succeeds. Neither requeue nor spend budget. + logger.notice( + "Advert delta sync not ready: dropping \(drained.count) pending key(s) until a full contact fetch completes" + ) + finishRound(generation: generation) + return + + case .failed: + consecutiveDeltaSyncFailures += 1 + lastDeltaSyncEnd = .now + finishRound(generation: generation) + + guard consecutiveDeltaSyncFailures < Self.maxConsecutiveDeltaSyncFailures else { + // Drop the drained work and wait for new adverts rather than retry an + // exchange the handler cannot complete. + // + // Path keys and escalateToFullRefetch were consumed into locals above; + // restore them so a pending path update or owed escalation is not + // silently dropped. Drained advert keys stay dropped — that is the + // cap's purpose. + logger.error( + "Advert delta sync failed \(Self.maxConsecutiveDeltaSyncFailures) times in a row; dropping \(drained.count) pending key(s)" + ) + consecutiveDeltaSyncFailures = 0 + // A 0x80/0x81 that arrived during this failing round already spent its + // one scheduleDeltaSync no-op against the still-set task, so no later + // event re-arms it. Detect that fresh work before restoring the round's + // own keys and re-arm for it; the min interval keeps it from spinning. + let freshWorkArrivedMidRound = hasPendingDeltaSyncWork + for key in drainedPathKeys where !contactsDeletedDuringSync.contains(key) { + pendingPathKeys.insert(key) + } + if fullRefetch { + escalateToFullRefetch = true + } + if freshWorkArrivedMidRound { + scheduleDeltaSync() + } + return + } + + requeueDrainedWork(drained, pathKeys: drainedPathKeys, fullRefetch: fullRefetch) + return + + case .synced: + break + } + + consecutiveDeltaSyncFailures = 0 + // Stamp phone recency for drained 0x80 keys that now have a row. Unknown + // contacts get lastHeard = 0 from Contact(radioID:from:); without this a + // stale radio lastModified makes matchesStaleNodePrune true right after hear. + await stampAdvertReceiveTimes(drainedReceiveTimes, radioID: radioID) + // Only keys proven absent before the round are announced as new. + let insertedKeysForNotify: Set = if let preRoundKnownKeys { + drained.subtracting(preRoundKnownKeys) + } else { + [] + } + await reconcile(drained, insertedKeys: insertedKeysForNotify) + // Adopt against every drained key that now has a row — not only + // insertedKeysForNotify — so a snapshot failure still links orphan DMs. + let adoptedContactIDs = await adoptOrphanedMessages(for: drained, radioID: radioID) + eventBroadcaster.yield(.contactUpdated) + // A non-nil lastMessageDate after adoption is a new/updated conversation + // row; contactsVersion alone does not reload the mounted chat list. The + // adopted DMs never notified at receipt (no contact then), so hand the + // contacts to the NotificationService owner for the banner and badge. + if !adoptedContactIDs.isEmpty { + eventBroadcaster.yield(.conversationsChanged) + eventBroadcaster.yield(.orphanDirectMessagesAdopted(contactIDs: adoptedContactIDs)) + } + + if !fullRefetch { + await escalateMissingUnknownKeys(unknownAtStart: unknownAtStart) + await escalateUndeliveredPathUpdates( + drainedPathKeys: drainedPathKeys, + preRoundLastModified: preRoundPathLastModified + ) + } else { + await dropStillMissingUnknownKeys(unknownAtStart: unknownAtStart) + } + + lastDeltaSyncEnd = .now + finishRound(generation: generation) + if hasPendingDeltaSyncWork { + scheduleDeltaSync() + } + } + + /// Re-arms after a busy outcome with a short backoff so performResync-style + /// claim holds do not spin at zero interval. + private func scheduleBusyRetry() { + guard deltaSyncTask == nil else { return } + guard hasPendingDeltaSyncWork else { return } + deltaSyncGeneration &+= 1 + let generation = deltaSyncGeneration + let fireAt = ContinuousClock.now + advertSyncBusyBackoff + deltaSyncTask = Task { [weak self] in + await self?.runDeltaSync(fireAt: fireAt, generation: generation) + } + } + + /// Returns a round's drained work to the pending set and re-arms the timer. + /// A rolled-back key stays dropped: refetching cannot bring it back. + /// Pass `shouldSchedule: false` when the caller re-arms with a different backoff. + private func requeueDrainedWork( + _ drained: Set, + pathKeys: Set, + fullRefetch: Bool, + shouldSchedule: Bool = true + ) { + // Keep a consumed full-refetch flag so a flaky escalated round-trip + // retries as full refetch rather than incremental. + if fullRefetch { + escalateToFullRefetch = true + } + for key in drained where !contactsDeletedDuringSync.contains(key) { + recordPendingAdvertKey(key) + } + for key in pathKeys where !contactsDeletedDuringSync.contains(key) { + pendingPathKeys.insert(key) + } + if shouldSchedule, hasPendingDeltaSyncWork { + scheduleDeltaSync() + } + } + + /// Removes contacts the radio deleted (0x8F) since this round drained its keys. + /// Runs before `reconcile` so a batch re-save cannot leave a Discover row for a + /// deleted contact. The keys stay recorded: reconcile and escalation read them. + /// + /// A key that re-advertised after the delete never reaches here: the radio + /// re-added it, so `recordPendingAdvertKey` clears the tombstone and the + /// re-created row is legitimate. A later delete re-arms the tombstone, so the + /// last event the radio sent always decides. + /// + /// Uses `deleteContactIfUnreferenced` so a concurrent DM attached after the + /// batch re-save is kept. Prefer an orphan contact over a cascade wipe. + private func rollBackContactsDeletedDuringSync(radioID: UUID?) async { + let keys = contactsDeletedDuringSync + guard let radioID, !keys.isEmpty else { return } + + for publicKey in keys { + let pubKeyHex = publicKey.uppercaseHexString() + do { + guard let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) else { continue } + try await dataStore.deleteContactIfUnreferenced(id: contact.id) + logger.info("Delta sync rollback: removed contact \(pubKeyHex) deleted by the radio mid-sync") + eventBroadcaster.yield(.contactUpdated) + } catch { + logger.error("Delta sync rollback failed for \(pubKeyHex): \(error.localizedDescription)") + } + } + } + + /// Refreshes Discover rows for drained 0x80 keys and emits new-contact + /// notifications only for keys this round actually inserted. + func reconcile(_ drained: Set, insertedKeys: Set) async { + guard let radioID = currentRadioID else { return } + + for publicKey in drained { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + let pubKeyHex = publicKey.uppercaseHexString() + do { + guard let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) else { + discoverTrace.info("B2 reconcile: no contact yet key=\(pubKeyHex)") + continue + } + // Re-check after each store hop; the radio can delete mid-round. + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + + // Use stored radio fields verbatim; do not stamp phone clock into + // lastAdvertTimestamp / lastModified (those remain radio-sourced). + let frame = ContactFrame( + publicKey: contact.publicKey, + type: contact.type, + flags: contact.flags, + outPathLength: contact.outPathLength, + outPath: contact.outPath, + name: contact.name, + lastAdvertTimestamp: contact.lastAdvertTimestamp, + latitude: contact.latitude, + longitude: contact.longitude, + lastModified: contact.lastModified + ) + + do { + let (_, isNew) = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) + discoverTrace.info("B2 reconcile upsert key=\(pubKeyHex) isNew=\(isNew)") + } catch { + discoverTrace.error("B2 reconcile upsert FAILED key=\(pubKeyHex): \(error.localizedDescription)") + } + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + + // Prefer losing a notification over inventing one: only keys absent from + // the pre-round snapshot and present after the exchange are announced. + if insertedKeys.contains(publicKey) { + eventBroadcaster.yield(.newContactDiscovered( + name: contact.name, + contactID: contact.id, + contactType: contact.type + )) + logOverwriteReplacementIfRecent( + newContactName: contact.name, + newContactType: contact.type + ) + } + } catch { + logger.error("Reconcile failed for \(pubKeyHex): \(error.localizedDescription)") + } + } + } + + /// Links orphaned DMs whose sender prefix matches contacts among `keys`. + /// `keys` is the drained set for this round (not only newly inserted keys) so + /// a failed pre-round snapshot cannot disable adoption. Springboard badge is + /// not updated here; the NotificationService owner refreshes it and posts the + /// banner from `.orphanDirectMessagesAdopted`. + /// + /// Returns the contact ids that adopted at least one message, so the caller + /// can emit `.conversationsChanged` and `.orphanDirectMessagesAdopted`. + private func adoptOrphanedMessages(for keys: Set, radioID: UUID?) async -> [UUID] { + guard let radioID, !keys.isEmpty else { return [] } + + var contacts: [(id: UUID, publicKey: Data)] = [] + contacts.reserveCapacity(keys.count) + for publicKey in keys { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + do { + if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { + contacts.append((contact.id, contact.publicKey)) + } + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error("Adoption contact lookup failed for \(pubKeyHex): \(error.localizedDescription)") + } + } + guard !contacts.isEmpty else { return [] } + + do { + let adopted = try await dataStore.adoptOrphanedDirectMessages( + radioID: radioID, contacts: contacts + ) + if !adopted.isEmpty { + logger.info("Adopted orphaned DMs for \(adopted.count) contact(s)") + } + return Array(adopted.keys) + } catch { + logger.error("Orphaned DM adoption failed: \(error.localizedDescription)") + return [] + } + } + + /// After a successful incremental sync, escalate once when keys that were + /// unknown at round start still lack a row. Keys present in the pre-round + /// snapshot stay out of scope (known contacts, including local deletes). + private func escalateMissingUnknownKeys(unknownAtStart: Set) async { + guard let radioID = currentRadioID else { return } + + var missing: Set = [] + for publicKey in unknownAtStart { + // A key the radio deleted is missing on purpose; refetching cannot bring it back. + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + guard let exists = await contactExists(radioID: radioID, publicKey: publicKey) else { continue } + if !exists { + missing.insert(publicKey) + } + } + guard !missing.isEmpty else { return } + + logger.info("Advert delta sync escalation: \(missing.count) unknown key(s) still missing") + escalateToFullRefetch = true + for key in missing { + recordPendingAdvertKey(key) + } + } + + /// Pre-round `lastModified` for known path keys. Keys with no local row are + /// omitted; post-round absence still counts as undelivered. + private func snapshotPathLastModified( + keys: Set, radioID: UUID? + ) async -> [Data: UInt32] { + guard let radioID, !keys.isEmpty else { return [:] } + var snapshot: [Data: UInt32] = [:] + snapshot.reserveCapacity(keys.count) + for publicKey in keys { + do { + if let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) { + snapshot[publicKey] = contact.lastModified + } + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error( + "Path lastModified snapshot failed for \(pubKeyHex): \(error.localizedDescription)" + ) + } + } + return snapshot + } + + /// After a successful incremental sync driven by 0x81, escalate once when a + /// path key was not refreshed. Firmware sets `lastmod` on path recv and only + /// reports contacts with `lastmod > since`, so a radio RTC reset or phone + /// clock step-back that lands the new lastmod at or below the stored + /// watermark returns an empty incremental stream. Known contacts never enter + /// `escalateMissingUnknownKeys`, so without this check out-path and + /// coordinates stay stale. One prune-free full refetch recovers; no per-key + /// `getContact` loop. + private func escalateUndeliveredPathUpdates( + drainedPathKeys: Set, + preRoundLastModified: [Data: UInt32] + ) async { + guard let radioID = currentRadioID, !drainedPathKeys.isEmpty else { return } + + var undelivered = 0 + for publicKey in drainedPathKeys { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + do { + guard let contact = try await dataStore.fetchContact( + radioID: radioID, publicKey: publicKey + ) else { + // Path key with no local row and no insert this round. + undelivered += 1 + continue + } + if let before = preRoundLastModified[publicKey], contact.lastModified <= before { + undelivered += 1 + } + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error( + "Path delivery check failed for \(pubKeyHex): \(error.localizedDescription)" + ) + } + } + guard undelivered > 0 else { return } + + logger.info( + "Advert path update escalation: \(undelivered) path key(s) not refreshed by incremental fetch" + ) + escalateToFullRefetch = true + } + + /// After an escalated full refetch, log keys that are still missing (no loop). + private func dropStillMissingUnknownKeys(unknownAtStart: Set) async { + guard let radioID = currentRadioID else { return } + + for publicKey in unknownAtStart { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + guard let exists = await contactExists(radioID: radioID, publicKey: publicKey) else { continue } + if !exists { + let pubKeyHex = publicKey.uppercaseHexString() + logger.info("Advert full refetch still missing key=\(pubKeyHex); dropping") + } + } + } + + /// Whether a Contact row exists locally, or nil when the store read failed. + /// Nil is not evidence the radio lacks the contact; callers must not escalate on nil. + private func contactExists(radioID: UUID, publicKey: Data) async -> Bool? { + do { + return try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) != nil + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error("Contact lookup failed for \(pubKeyHex): \(error.localizedDescription)") + return nil + } + } +} diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift index 28fe95651..12acb72da 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift @@ -22,6 +22,21 @@ extension AdvertisementError: LocalizedError { } } +// MARK: - Advert Contact Sync Outcome + +/// Result of one advert-driven contact delta exchange. +/// +/// `busy` is not `failed`: a collision with another sync never reaches the +/// radio, so it must not spend the failure budget that drops drained keys. +/// `notReady` is permanent until a full contact fetch succeeds: neither requeue +/// nor spend the failure budget. +public enum AdvertContactSyncOutcome: Sendable { + case synced + case busy + case failed + case notReady +} + // MARK: - Advertisement Service /// Service for managing device advertisements and discovery. @@ -38,28 +53,58 @@ public actor AdvertisementService { let dataStore: any PersistenceStoreProtocol private var eventMonitorTask: Task? - private var currentRadioID: UUID? + var currentRadioID: UUID? - /// When true, the contact-fetch worker is paused (e.g. during contact sync). + /// When true, advert delta sync is deferred (full or manual contact sync). + /// Manual pull-to-refresh also sets this; it is invisible to `SyncCoordinator.isSyncInProgress`. var isSyncingContacts = false - /// Pending `getContact` work, deduped by key. Advert reason wins on merge - /// because its post-fetch work is a superset of pathUpdate's. - var contactFetchQueue: [Data: ContactFetchEntry] = [:] - var contactFetchWorker: Task? - /// Bumped when a worker starts; an exiting worker nils the ref only when - /// generations match so it cannot clobber a newly started worker. - var contactFetchWorkerGeneration: UInt64 = 0 + /// 0x80 keys heard since the last delta sync. + var pendingAdvertKeys: Set = [] + + /// Phone receive times for pending 0x80 keys, keyed by public key. + /// Stamped onto `lastHeardTimestamp` after a delta insert (or materialize) + /// so a stale radio RTC cannot prune a contact just heard on air. + private var pendingAdvertReceiveTimes: [Data: Date] = [:] + + /// 0x81 path-update keys waiting for the next delta sync. Tracked apart from + /// `pendingAdvertKeys` because a path key must not create a Discover row. + /// Keys (not a bool) so a successful incremental round can verify delivery + /// when radio `lastmod` falls at or below the stored watermark. + var pendingPathKeys: Set = [] - /// Key currently being fetched, and whether 0x8F or teardown cancelled it - /// mid-flight. A cancelled fetch must not commit. - var inFlightKey: Data? - var inFlightCancelled = false + /// True when any 0x81 path key is waiting for delta sync. + var pathSyncPending: Bool { + !pendingPathKeys.isEmpty + } - /// Waiters for the current snapshotted worker pass. Concurrent - /// `setSyncingContacts(false)` callers share one pass; late joiners wait - /// for the next pass only — never drain-until-empty under live enqueues. - var barrierWaiters: [CheckedContinuation] = [] + /// Keys the radio deleted (0x8F) since this round drained its keys. Rolled back so + /// a batch cannot re-save a dropped row, and skipped while reconciling. Cleared + /// when the next round drains, so a between-round delete never undoes a later + /// re-created contact. + var contactsDeletedDuringSync: Set = [] + + var deltaSyncTask: Task? + /// Monotonic identity for the scheduled delta-sync round. `finishRound` clears + /// `deltaSyncTask` only while this still matches the running round. + var deltaSyncGeneration: UInt64 = 0 + var lastDeltaSyncEnd: ContinuousClock.Instant? + var deltaSyncHandler: (@Sendable (_ fullRefetch: Bool) async -> AdvertContactSyncOutcome)? + /// One-shot: the next delta sync runs as a prune-free full fetch. + var escalateToFullRefetch = false + + /// Delta syncs that failed back to back without an intervening success. + var consecutiveDeltaSyncFailures = 0 + + /// Failed delta syncs tolerated before a round drops its drained keys. Without a + /// cap, a handler that never succeeds repeats a full contact exchange forever. + static let maxConsecutiveDeltaSyncFailures = 5 + + let advertSyncDebounce: Duration + let advertSyncMinInterval: Duration + /// Backoff before re-arming after a `.busy` outcome. Shorter than + /// `advertSyncMinInterval` so claim collisions poll cheaply without spinning. + let advertSyncBusyBackoff: Duration /// Last overwrite-oldest deletion, used to correlate the replacement advert (0x8F then new contact). private var lastOverwriteDeletion: (name: String, pubKeyHex: String, time: Date)? @@ -70,18 +115,25 @@ public actor AdvertisementService { // MARK: - Initialization - public init(session: any AdvertisingSessionOps & SessionEventStreaming, dataStore: any PersistenceStoreProtocol) { + public init( + session: any AdvertisingSessionOps & SessionEventStreaming, + dataStore: any PersistenceStoreProtocol, + advertSyncDebounce: Duration = .seconds(5), + advertSyncMinInterval: Duration = .seconds(30), + advertSyncBusyBackoff: Duration = .seconds(5) + ) { self.session = session self.dataStore = dataStore + self.advertSyncDebounce = advertSyncDebounce + self.advertSyncMinInterval = advertSyncMinInterval + self.advertSyncBusyBackoff = advertSyncBusyBackoff } - /// Cancels outstanding tasks only. Full queue and barrier teardown is - /// ``stopEventMonitoring`` (`ServiceContainer.tearDown`); deinit cannot - /// safely resume waiters. Commit paths also treat `Task.isCancelled` as - /// cancel eligibility so work does not land after teardown begins. + /// Cancels outstanding tasks only. Full teardown is ``stopEventMonitoring`` + /// (`ServiceContainer.tearDown`); deinit cannot safely clear the handler. deinit { eventMonitorTask?.cancel() - contactFetchWorker?.cancel() + deltaSyncTask?.cancel() } // MARK: - Events @@ -130,32 +182,153 @@ public actor AdvertisementService { } } - /// Stops event monitoring and tears down the contact-fetch worker - /// (queue, in-flight cancel, barrier waiters). Sole full teardown path. + /// Stops event monitoring and clears advert delta-sync state. public func stopEventMonitoring() { eventMonitorTask?.cancel() eventMonitorTask = nil currentRadioID = nil - cancelContactFetchWorkerAndClearQueue() + deltaSyncTask?.cancel() + deltaSyncTask = nil + deltaSyncHandler = nil + pendingAdvertKeys.removeAll() + pendingPathKeys.removeAll() + pendingAdvertReceiveTimes.removeAll() + escalateToFullRefetch = false + consecutiveDeltaSyncFailures = 0 + isSyncingContacts = false + // Keep `contactsDeletedDuringSync` across teardown so a commit still in flight + // can roll back rows the radio deleted; the next round clears it when it drains. } - /// Toggle deferred contact fetching during sync. + /// Records a 0x80 key for the next delta sync. + /// + /// A re-advert for a key in `contactsDeletedDuringSync` means the radio + /// re-added the contact after a mid-round 0x8F. Clear the tombstone so + /// rollback, reconcile, and adoption treat the re-synced row as live. /// - /// When `false`, starts the single contact-fetch worker (if idle) and awaits - /// one snapshotted pass — not drain-until-empty. Concurrent callers share the - /// same in-flight pass; callers that join after a pass boundary wait for the - /// next snapshotted pass only. + /// `receivedAt` is the phone clock at the 0x80 (or re-record). It is applied + /// to `lastHeardTimestamp` once a Contact row exists so prune recency is not + /// hostage to a stale radio RTC. + func recordPendingAdvertKey(_ key: Data, receivedAt: Date = Date()) { + pendingAdvertKeys.insert(key) + contactsDeletedDuringSync.remove(key) + pendingAdvertReceiveTimes[key] = receivedAt + } + + /// Captures and removes phone receive times for keys about to drain. + func takeAdvertReceiveTimes(for keys: Set) -> [Data: Date] { + var times: [Data: Date] = [:] + times.reserveCapacity(keys.count) + for key in keys { + if let receivedAt = pendingAdvertReceiveTimes.removeValue(forKey: key) { + times[key] = receivedAt + } + } + return times + } + + /// Stamps `lastHeardTimestamp` for drained 0x80 keys that now have a Contact + /// row, using each key's recorded phone receive time. Uses + /// `touchContactHeard` so clamping matches the live 0x80 path. + func stampAdvertReceiveTimes(_ receiveTimes: [Data: Date], radioID: UUID?) async { + guard let radioID, !receiveTimes.isEmpty else { return } + for (publicKey, receivedAt) in receiveTimes { + guard !contactsDeletedDuringSync.contains(publicKey) else { continue } + do { + _ = try await dataStore.touchContactHeard( + radioID: radioID, publicKey: publicKey, at: receivedAt + ) + } catch { + let pubKeyHex = publicKey.uppercaseHexString() + logger.error( + "Post-delta lastHeard stamp failed for \(pubKeyHex): \(error.localizedDescription)" + ) + } + } + } + + /// True when a delta sync still has work: pending 0x80 keys, a path update, + /// or an owed prune-free full refetch. Re-arm sites and the empty-round + /// guard share this definition so a future flag cannot desynchronise them. + var hasPendingDeltaSyncWork: Bool { + !pendingAdvertKeys.isEmpty || pathSyncPending || escalateToFullRefetch + } + + /// Creates a local `Contact` from a pending 0x80 key so a DM that arrives + /// inside the advert delta-sync debounce window can notify and list normally. + /// + /// Matches `prefix` against `pendingAdvertKeys` only (keys the radio already + /// auto-added). Exactly one match is required; ambiguous prefixes return nil. + /// The key stays pending so the debounced delta round still reconciles Discover + /// and may emit `.newContactDiscovered`. + /// + /// - Returns: The persisted contact, or nil when no unique pending key matches + /// or the radio fetch / save fails. + public func materializeContactForPendingAdvert( + matchingPrefix prefix: Data, + radioID: UUID + ) async -> ContactDTO? { + guard !prefix.isEmpty else { return nil } + + let matches = pendingAdvertKeys.filter { $0.starts(with: prefix) } + guard matches.count == 1, let publicKey = matches.first else { return nil } + + let pubKeyHex = publicKey.uppercaseHexString() + do { + guard let meshContact = try await session.getContact(publicKey: publicKey) else { + logger.info("materialize pending advert: getContact nil key=\(pubKeyHex)") + return nil + } + let frame = meshContact.toContactFrame() + let saveResult = try await dataStore.saveContact(radioID: radioID, from: frame) + // Stamp phone recency before the returned DTO is read so a debounce-window + // insert is not prune-eligible under a stale radio lastModified. + let receivedAt = pendingAdvertReceiveTimes[publicKey] ?? Date() + do { + _ = try await dataStore.touchContactHeard( + radioID: radioID, publicKey: publicKey, at: receivedAt + ) + } catch { + logger.error( + "materialize pending advert lastHeard stamp failed key=\(pubKeyHex): \(error.localizedDescription)" + ) + } + do { + _ = try await dataStore.upsertDiscoveredNode(radioID: radioID, from: frame) + } catch { + logger.error( + "materialize pending advert Discover upsert failed key=\(pubKeyHex): \(error.localizedDescription)" + ) + } + eventBroadcaster.yield(.contactUpdated) + return try await dataStore.fetchContact(id: saveResult.id) + } catch { + logger.error( + "materialize pending advert failed key=\(pubKeyHex): \(error.localizedDescription)" + ) + return nil + } + } + + /// Installs the contact delta-sync handler used after debounced 0x80/0x81 events. + /// A non-nil handler re-arms when work is already pending (adverts, path updates, + /// or an owed full refetch that arrived before wiring). + public func setDeltaSyncHandler( + _ handler: (@Sendable (_ fullRefetch: Bool) async -> AdvertContactSyncOutcome)? + ) { + deltaSyncHandler = handler + if handler != nil, hasPendingDeltaSyncWork { + scheduleDeltaSync() + } + } + + /// Toggle deferred contact sync during a full or manual contact sync. + /// When set to `false` with pending work, re-arms so a mid-sync debounce does + /// not drop keys, path updates, or an owed full refetch. public func setSyncingContacts(_ isSyncing: Bool) async { isSyncingContacts = isSyncing - if !isSyncing { - startContactFetchWorkerIfNeeded() - await withCheckedContinuation { (continuation: CheckedContinuation) in - barrierWaiters.append(continuation) - // Idle worker means no pass is running; resume immediately. - if contactFetchWorker == nil { - resumeBarrierWaiters() - } - } + if !isSyncing, hasPendingDeltaSyncWork { + scheduleDeltaSync() } } @@ -238,49 +411,49 @@ public actor AdvertisementService { // MARK: - Private Event Handlers - /// Handle advertisement event - Existing contact updated + /// 0x80 change notification: the radio already updated a contact row. private func handleAdvertEvent(publicKey: Data, radioID: UUID) async { let pubKeyHex = publicKey.uppercaseHexString() logger.debug("Advert event for \(pubKeyHex)") discoverTrace.info("B1 0x80 ADVERT received key=\(pubKeyHex)") - let timestamp = UInt32(Date().timeIntervalSince1970) + // One phone clock for touch and pending receive-time so the post-delta + // stamp matches the air-hear moment (not a later debounce fire). + let receivedAt = Date() + // Retry touch once: a transient store error must not permanently drop the + // advert under the empty-round guard. Recording without a successful touch + // is also wrong (it can announce a long-known contact as new). + var known: Bool? do { - if let contact = try await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) { - 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) + known = try await dataStore.touchContactHeard( + radioID: radioID, publicKey: publicKey, at: receivedAt + ) + } catch { + logger.error("Error handling advert event (retrying once): \(error.localizedDescription)") + do { + known = try await dataStore.touchContactHeard( + radioID: radioID, publicKey: publicKey, at: receivedAt ) - _ = try await dataStore.saveContact(radioID: radioID, from: frame) - - // Keep DiscoveredNode in sync so Discover stays visible for known contacts. - 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)") - } + } catch { + logger.error("Advert touch retry failed: \(error.localizedDescription)") + known = nil + } + } + if let known { + if known { eventBroadcaster.yield(.contactUpdated) } else { discoverTrace.info("B2 0x80 no local contact key=\(pubKeyHex) syncing=\(isSyncingContacts)") - // Device has the contact, local store does not (auto-add). Enqueue - // getContact; never await BLE on the event-handler path. - logger.info("ADVERT received for unknown contact - enqueueing fetch") - enqueueContactFetch(publicKey, reason: .advert, radioID: radioID) + logger.info("ADVERT received for unknown contact - scheduling delta sync") } - } catch { - logger.error("Error handling advert event: \(error.localizedDescription)") + recordPendingAdvertKey(publicKey, receivedAt: receivedAt) } + + // Schedule so other pending keys or a path update still run; the empty-round + // guard no-ops when this advert alone failed both touch attempts. + scheduleDeltaSync() } /// Handle new advertisement event - New contact discovered (manual add mode) @@ -308,12 +481,16 @@ public actor AdvertisementService { } } - /// Path changed: enqueue getContact rather than awaiting BLE on the event path. - /// Shared worker defers while `isSyncingContacts` is true. + /// Path changed on the radio: record the key and schedule contact delta sync. + /// Path keys must not create Discover rows; path changes are not on-air heard evidence. + /// Delivery is via watermark-filtered GET_CONTACTS; if radio `lastmod` is at or + /// below the stored watermark the incremental round returns nothing and the + /// post-round path check escalates once to a prune-free full refetch. private func handlePathUpdatedEvent(publicKey: Data, radioID: UUID) async { let pubKeyHex = publicKey.uppercaseHexString() logger.debug("Path updated event for \(pubKeyHex)") - enqueueContactFetch(publicKey, reason: .pathUpdate, radioID: radioID) + pendingPathKeys.insert(publicKey) + scheduleDeltaSync() } /// Handle path discovery response event @@ -381,13 +558,14 @@ public actor AdvertisementService { return } - // Cancel in-flight / queued fetches for this key before the local-row guard. - // Unknown-key fetches have no local row; without cancel a late save can - // resurrect the contact after 0x8F. - contactFetchQueue.removeValue(forKey: publicKey) - if inFlightKey == publicKey { - inFlightCancelled = true - } + // Drop pending reconcile / path keys so a deleted contact is not re-notified + // and a surviving path key cannot escalate to an epoch-0 full refetch. + pendingAdvertKeys.remove(publicKey) + pendingPathKeys.remove(publicKey) + pendingAdvertReceiveTimes.removeValue(forKey: publicKey) + // A running delta sync can re-save or reconcile this row after the radio dropped + // it. A key recorded outside a round is discarded when the next round drains. + contactsDeletedDuringSync.insert(publicKey) logger.info("Overwrite oldest: device deleted contact with key \(pubKeyPrefix)...") diff --git a/MC1Services/Sources/MC1Services/Services/ContactService.swift b/MC1Services/Sources/MC1Services/Services/ContactService.swift index ce4e9e2d6..1ad12bcda 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactService.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactService.swift @@ -188,6 +188,38 @@ public actor ContactService { } } + /// Full contact sync for a user-initiated refresh. + /// + /// Atomically waits out an advert-driven delta sync and claims the manual + /// refresh flag on the coordinator. Separate wait and claim hops leave a + /// window where a delta can take the claim after the wait returns and before + /// the flag is set; both publish `syncProgress` on the same event stream, so + /// that race jumps the pull-to-refresh counter. Callers still call + /// `AdvertisementService.setSyncingContacts(true)` first so no new delta + /// starts before this claim runs. + /// + /// - Parameter radioID: The device to sync from + /// - Returns: Sync result with count and timestamp + public func syncContactsForRefresh(radioID: UUID) async throws -> ContactSyncResult { + do { + try await syncCoordinator?.claimManualContactSync() + } catch is CancellationError { + throw CancellationError() + } catch { + // Wait timed out while an advert delta still held the claim. Do not + // proceed (races progress) and do not silent-skip: surface so the spinner stops. + throw ContactServiceError.syncInterrupted + } + do { + let result = try await syncContacts(radioID: radioID) + await syncCoordinator?.setManualContactSyncActive(false) + return result + } catch { + await syncCoordinator?.setManualContactSyncActive(false) + throw error + } + } + // MARK: - Get Contact /// Get a specific contact by public key from local database @@ -502,6 +534,7 @@ public actor ContactService { latitude: existing.latitude, longitude: existing.longitude, lastModified: existing.lastModified, + lastHeardTimestamp: existing.lastHeardTimestamp ?? 0, nickname: resolvedNickname, isBlocked: isBlocked ?? existing.isBlocked, isMuted: existing.isMuted, @@ -553,6 +586,7 @@ public actor ContactService { latitude: existing.latitude, longitude: existing.longitude, lastModified: existing.lastModified, + lastHeardTimestamp: existing.lastHeardTimestamp ?? 0, nickname: existing.nickname, isBlocked: existing.isBlocked, isMuted: existing.isMuted, @@ -639,6 +673,7 @@ public actor ContactService { latitude: existing.latitude, longitude: existing.longitude, lastModified: existing.lastModified, + lastHeardTimestamp: existing.lastHeardTimestamp ?? 0, nickname: existing.nickname, isBlocked: existing.isBlocked, isMuted: existing.isMuted, @@ -717,6 +752,7 @@ public actor ContactService { latitude: existing.latitude, longitude: existing.longitude, lastModified: existing.lastModified, + lastHeardTimestamp: existing.lastHeardTimestamp ?? 0, nickname: existing.nickname, isBlocked: existing.isBlocked, isMuted: existing.isMuted, diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift index 8924832c6..b1bd41b4a 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupBatchInsert.swift @@ -119,6 +119,9 @@ extension PersistenceStore { continue } let contact = Contact(dto: dto) + // Same future-clock clamp as mergeBackupMetadata so an insert-only + // future stamp cannot pin sort/prune under max-wins. + contact.lastHeardTimestamp = clampedBackupLastHeardTimestamp(dto.lastHeardTimestamp) modelContext.insert(contact) existingContactsByKey[key] = contact contactIDsByKey[key] = contact.id diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift index d70f7928d..8c9475d9c 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupHelpers.swift @@ -232,9 +232,31 @@ extension PersistenceStore { contact.avatarImageData = backupAvatar changed = true } + let clampedHeard = clampedBackupLastHeardTimestamp(dto.lastHeardTimestamp) + if clampedHeard > contact.lastHeardTimestamp { + contact.lastHeardTimestamp = clampedHeard + changed = true + } return changed } + /// Clamps a backup phone-clock stamp so a future exporting clock cannot pin + /// sort/prune under max-wins. Nil (legacy envelope) becomes 0. + /// Shares the same clamp as `touchContactHeard` so export-import is not lossy. + func clampedBackupLastHeardTimestamp(_ stamp: UInt32?, at now: Date = Date()) -> UInt32 { + guard let stamp else { return 0 } + return Self.clampedPhoneClockTimestamp(stamp, at: now) + } + + /// Upper-bounds a phone-clock second stamp to now + `timestampToleranceFuture`. + /// Used by live `touchContactHeard` and backup import so both agree. + static func clampedPhoneClockTimestamp(_ stamp: UInt32, at now: Date = Date()) -> UInt32 { + let nowSeconds = UInt32(now.timeIntervalSince1970) + let tolerance = UInt32(SyncCoordinator.timestampToleranceFuture) + let upperBound = nowSeconds > UInt32.max - tolerance ? UInt32.max : nowSeconds + tolerance + return min(stamp, upperBound) + } + func mergeBackupMetadata(into channel: Channel, from dto: ChannelDTO) -> Bool { var changed = false if let backupDate = dto.lastMessageDate { diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift index 593857318..6cbf3c774 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Contacts.swift @@ -174,7 +174,8 @@ public extension PersistenceStore { } /// Insert-only rollback: probe for messages and delete in one ModelActor - /// region with no suspension between them. + /// region with no suspension between them. Prefer an orphan contact over a + /// cascade that wipes a concurrent DM. func deleteContactIfUnreferenced(id: UUID) throws { let targetID = id let messagePredicate = #Predicate { message in @@ -188,6 +189,159 @@ public extension PersistenceStore { try deleteContact(id: id) } + /// Links direct messages stored before their contact row existed. Channel + /// rows are excluded by their non-nil channelIndex; the sender-key prefix + /// match runs in memory because `#Predicate` cannot express `Data.prefix`. + /// A prefix matching two contacts is left orphaned rather than guessed. + /// + /// Once a DM is adopted, `deleteContactIfUnreferenced` cannot roll that + /// contact back. Ghost contacts become permanent. That is the intended trade. + /// + /// Reaction-format wire text is skipped so it does not become a chat bubble. + /// Blocked contacts get no unread or mention bump. Dedup keys are recomputed + /// with the adopted contact id so a later re-delivery does not duplicate. + @discardableResult + func adoptOrphanedDirectMessages( + radioID: UUID, + contacts: [(id: UUID, publicKey: Data)] + ) async throws -> [UUID: Int] { + guard !contacts.isEmpty else { return [:] } + + let targetRadioID = radioID + let nilContactID: UUID? = nil + let nilChannel: UInt8? = nil + let incoming = MessageDirection.incoming.rawValue + // Scoped predicate with typed nils. Prefix match stays in memory because + // #Predicate cannot express Data.prefix. + let predicate = #Predicate { message in + message.radioID == targetRadioID + && message.contactID == nilContactID + && message.channelIndex == nilChannel + && message.directionRawValue == incoming + } + let orphans = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + guard !orphans.isEmpty else { return [:] } + + var contactByID: [UUID: Contact] = [:] + contactByID.reserveCapacity(contacts.count) + for candidate in contacts { + let targetID = candidate.id + let contactPredicate = #Predicate { contact in + contact.id == targetID + } + var descriptor = FetchDescriptor(predicate: contactPredicate) + descriptor.fetchLimit = 1 + if let row = try modelContext.fetch(descriptor).first { + contactByID[row.id] = row + } + } + + var adoptedCounts: [UUID: Int] = [:] + var newestDateByContact: [UUID: Date] = [:] + var unreadByContact: [UUID: Int] = [:] + var mentionByContact: [UUID: Int] = [:] + + for message in orphans { + guard let prefix = message.senderKeyPrefix, !prefix.isEmpty else { continue } + if ReactionParser.isReactionText(message.text, isDM: true) { + continue + } + + let matches = contacts.filter { candidate in + candidate.publicKey.starts(with: prefix) + } + guard matches.count == 1, let match = matches.first else { continue } + guard contactByID[match.id] != nil else { continue } + + message.contactID = match.id + message.deduplicationKey = DeduplicationKey.contentBased( + contactID: match.id, + channelIndex: nil, + senderNodeName: message.senderNodeName, + timestamp: message.timestamp, + content: message.text + ) + + adoptedCounts[match.id, default: 0] += 1 + let messageDate = message.sortDate + if let existing = newestDateByContact[match.id] { + newestDateByContact[match.id] = max(existing, messageDate) + } else { + newestDateByContact[match.id] = messageDate + } + if !message.isRead { + unreadByContact[match.id, default: 0] += 1 + } + if message.containsSelfMention, !message.mentionSeen { + mentionByContact[match.id, default: 0] += 1 + } + } + + guard !adoptedCounts.isEmpty else { return [:] } + + for contactID in adoptedCounts.keys { + guard let contact = contactByID[contactID] else { continue } + if let newest = newestDateByContact[contactID] { + if let existing = contact.lastMessageDate { + contact.lastMessageDate = max(existing, newest) + } else { + contact.lastMessageDate = newest + } + } + // Blocked contacts gain no unread or mention bump (live path parity). + if !contact.isBlocked { + if let unread = unreadByContact[contactID], unread > 0 { + contact.unreadCount += unread + } + if let mentions = mentionByContact[contactID], mentions > 0 { + contact.unreadMentionCount += mentions + } + } + } + + try modelContext.save() + return adoptedCounts + } + + /// Stamps phone-clock recency for a contact heard on air and the matching + /// DiscoveredNode lastHeard, creating that row from stored radio fields when missing. + /// Returns true when a Contact row existed. + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) throws -> Bool { + let targetRadioID = radioID + let targetKey = publicKey + let contactPredicate = #Predicate { contact in + contact.radioID == targetRadioID && contact.publicKey == targetKey + } + var contactDescriptor = FetchDescriptor(predicate: contactPredicate) + contactDescriptor.fetchLimit = 1 + + guard let contact = try modelContext.fetch(contactDescriptor).first else { + return false + } + + let rawStamp = UInt32(date.timeIntervalSince1970) + let stamp = Self.clampedPhoneClockTimestamp(rawStamp, at: date) + contact.lastHeardTimestamp = max(contact.lastHeardTimestamp, stamp) + + let nodePredicate = #Predicate { node in + node.radioID == targetRadioID && node.publicKey == targetKey + } + var nodeDescriptor = FetchDescriptor(predicate: nodePredicate) + nodeDescriptor.fetchLimit = 1 + if try modelContext.fetch(nodeDescriptor).first == nil { + // A known contact can lack a Discover row (paired before the row existed, + // or restored from a backup). Hearing it on air makes it discoverable. + _ = try upsertDiscoveredNode(radioID: radioID, from: contact.toContactFrame()) + } + if let node = try modelContext.fetch(nodeDescriptor).first { + node.lastHeard = date + } + + try modelContext.save() + return true + } + /// Fetch all blocked contacts for a device func fetchBlockedContacts(radioID: UUID) throws -> [ContactDTO] { let targetRadioID = radioID diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift index 992707648..fafea4e2e 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Devices.swift @@ -100,6 +100,12 @@ public extension PersistenceStore { /// Update the lastContactSync timestamp for a device. /// Used to track incremental sync progress. + /// + /// The stamp is radio-RTC `max(contact.lastmod)`, not phone time. Store it + /// verbatim. Firmware filters with `contact.lastmod > since`; a phone-clock + /// upper bound can pin the watermark below every lastmod when the radio RTC + /// leads the phone, so every delta re-matches the full table and never + /// converges. func updateDeviceLastContactSync(radioID: UUID, timestamp: UInt32) throws { let targetRadioID = radioID let predicate = #Predicate { device in diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift index cdcb20f85..6ba06fec0 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift @@ -64,6 +64,27 @@ public extension PersistenceStore { return MessageDTO.reorderSameSenderClusters(dtos) } + func newestUnreadIncomingMessage(contactID: UUID) async throws -> MessageDTO? { + let targetContactID: UUID? = contactID + let incoming = MessageDirection.incoming.rawValue + let predicate = #Predicate { message in + message.contactID == targetContactID + && message.directionRawValue == incoming + && !message.isRead + } + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.sortDate, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse), + SortDescriptor(\Message.createdAt, order: .reverse) + ] + ) + descriptor.fetchLimit = 1 + guard let message = try modelContext.fetch(descriptor).first else { return nil } + return MessageDTO(from: message) + } + /// Fetch messages for a channel func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { let targetRadioID = radioID diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift index 94c151032..cc612115a 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift @@ -20,6 +20,7 @@ public extension MockDataProvider { latitude: 37.7849, longitude: -122.4094, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -42,6 +43,7 @@ public extension MockDataProvider { latitude: 37.7649, longitude: -122.4294, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -64,6 +66,7 @@ public extension MockDataProvider { latitude: 37.7549, longitude: -122.4394, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -86,6 +89,7 @@ public extension MockDataProvider { latitude: 37.7449, longitude: -122.4494, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -108,6 +112,7 @@ public extension MockDataProvider { latitude: 37.7349, longitude: -122.4594, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: true, isMuted: false, @@ -130,6 +135,7 @@ public extension MockDataProvider { latitude: 37.7249, longitude: -122.4694, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: "Dad", isBlocked: false, isMuted: false, @@ -152,6 +158,7 @@ public extension MockDataProvider { latitude: 0, longitude: 0, lastModified: UInt32(now.timeIntervalSince1970) - 86400, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -174,6 +181,7 @@ public extension MockDataProvider { latitude: 37.7149, longitude: -122.4794, lastModified: UInt32(now.timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift index 89b486fe4..6eb5787b9 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift @@ -72,6 +72,28 @@ extension SyncCoordinator { } } + /// When a DM arrives with no local contact, try to create the row from a + /// pending 0x80 key before save/notify. Adoption still links pure orphans + /// without posting a second alert. + private func resolveDirectContactIfNeeded( + _ kind: IncomingMessageKind, + dependencies: SyncDependencies, + radioID: UUID + ) async -> IncomingMessageKind { + guard case let .direct(message, contact) = kind, contact == nil else { + return kind + } + let prefix = message.senderPublicKeyPrefix + guard !prefix.isEmpty else { return kind } + + guard let materialized = await dependencies.advertisementService + .materializeContactForPendingAdvert(matchingPrefix: prefix, radioID: radioID) + else { + return kind + } + return .direct(message, contact: materialized) + } + /// 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. @@ -82,6 +104,13 @@ extension SyncCoordinator { radioID: UUID, selfNodeName: String ) async { + // A DM can land in the advert delta-sync debounce window before the local + // Contact row exists. Materialize from a pending 0x80 key so unread, + // notification, and Chats-list updates use the live path exactly once. + let resolvedKind = await resolveDirectContactIfNeeded( + kind, dependencies: dependencies, radioID: radioID + ) + // Per-kind wire fields. Channel messages embed the sender as a // "NodeName: text" prefix; direct messages carry the sender key prefix. let text: String @@ -93,7 +122,7 @@ extension SyncCoordinator { let contactID: UUID? let channelIndex: UInt8? let senderKeyPrefix: Data? - switch kind { + switch resolvedKind { 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 @@ -125,7 +154,7 @@ extension SyncCoordinator { 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)") + logger.debug("Corrected invalid \(resolvedKind.logLabel) message timestamp from \(Date(timeIntervalSince1970: TimeInterval(timestamp))) to \(receiveTime)") } let sortDate = Self.sortDate(for: context, receiveTime: receiveTime) @@ -151,7 +180,7 @@ extension SyncCoordinator { // Check for self-mention before creating DTO // For channel messages, filter out messages where the user mentions themselves - let hasSelfMention: Bool = switch kind { + let hasSelfMention: Bool = switch resolvedKind { case .direct: !selfNodeName.isEmpty && MentionUtilities.containsSelfMention(in: text, selfName: selfNodeName) @@ -169,7 +198,7 @@ extension SyncCoordinator { if let parsed = TextType(rawValue: textTypeRaw) { resolvedTextType = parsed } else { - logger.warning("Unknown \(kind.logLabel) message textType raw=\(textTypeRaw); clamping to .plain") + logger.warning("Unknown \(resolvedKind.logLabel) message textType raw=\(textTypeRaw); clamping to .plain") resolvedTextType = .plain } @@ -211,14 +240,14 @@ extension SyncCoordinator { // Check for duplicate before saving do { if try await dependencies.dataStore.isDuplicateMessage(deduplicationKey: deduplicationKey, radioID: radioID) { - logger.info("Skipping duplicate \(kind.logLabel) message") + logger.info("Skipping duplicate \(resolvedKind.logLabel) message") return } } catch { logger.warning("Dedup check failed, proceeding with save: \(error)") } - switch kind { + switch resolvedKind { case let .direct(_, contact): // Check if this is a DM reaction if let contact, @@ -253,7 +282,7 @@ extension SyncCoordinator { do { try await dependencies.dataStore.saveMessage(messageDTO) - switch kind { + switch resolvedKind { case let .direct(_, contact): try await indexAndNotifyDirectMessage( messageDTO: messageDTO, @@ -282,11 +311,11 @@ extension SyncCoordinator { await notifyConversationsChanged() // Broadcast for real-time chat updates - if case let .direct(_, contact) = kind, let contact { + if case let .direct(_, contact) = resolvedKind, let contact { dataEventBroadcaster.yield(.directMessageReceived(message: messageDTO, contact: contact)) } } catch { - switch kind { + switch resolvedKind { case .direct: logger.error("Failed to save contact message: \(error)") case .channel: @@ -528,8 +557,37 @@ extension SyncCoordinator { contactType: contactType ) await notifyContactsChanged() - case .contactUpdated, .nodeStorageFullChanged, .contactDeletedCleanup, - .pathDiscoveryResponse, .traceResponse, .traceSnrObserved: + case let .orphanDirectMessagesAdopted(contactIDs): + // These DMs arrived before their sender's contact existed, so no + // banner or badge fired at receipt. Adoption bumped the unread counts; + // post the banner and refresh the badge now, once per contact. The + // final badge refresh also covers a muted contact, whose unread rose + // but whose suppressed banner never updates the badge itself. + for contactID in contactIDs { + do { + guard let contact = try await dependencies.dataStore.fetchContact(id: contactID), + !contact.isBlocked, + let message = try await dependencies.dataStore.newestUnreadIncomingMessage( + contactID: contactID + ) + else { continue } + await dependencies.notificationService.postDirectMessageNotification( + from: contact.displayName, + contactID: contactID, + messageText: message.text, + messageID: message.id, + isMuted: contact.isMuted + ) + } catch { + logger.error( + "Adopted DM notification failed for \(contactID): \(error.localizedDescription)" + ) + } + } + await dependencies.notificationService.updateBadgeCount() + case .contactUpdated, .conversationsChanged, .nodeStorageFullChanged, + .contactDeletedCleanup, .pathDiscoveryResponse, .traceResponse, + .traceSnrObserved: break } } diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift index bf0d06da0..1558a8121 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift @@ -10,6 +10,78 @@ extension SyncCoordinator { let channelRetryIndices: [UInt8] } + /// Contact watermark value meaning no contact sync has ever succeeded. + static let noContactWatermark: UInt32 = 0 + + /// `since` value for a prune-free full contact fetch: the device reports every + /// contact while local rows the device omits are kept. + private static let pruneFreeFullFetchSince = Date(timeIntervalSince1970: 0) + + /// Seconds the incremental `since` filter is rewound from the stored watermark. + /// The device reports a contact only when `lastmod > since`, so a contact modified + /// in the same second as the watermark would never be reported. One second of + /// overlap re-reports that second; the upsert behind it is idempotent. + private static let incrementalSinceOverlap: TimeInterval = 1 + + /// Max lead over the plausibility reference before a stored watermark is + /// treated as unusable for incremental `since`. Multi-day residual lastmod + /// from a radio RTC far ahead of the phone is broken; minute-scale lead is + /// normal drift. + static let contactWatermarkPlausibilitySkew: TimeInterval = 2 * 24 * 60 * 60 + + /// Bound for waiting out an advert-owned contact-sync claim. + /// Above `SessionConfiguration.contactStreamHardTimeout` (180s) so a large + /// contact table can finish streaming, but a wedged wait cannot hang forever. + static let advertContactSyncWaitTimeout: Duration = .seconds(200) + + /// Developer-facing reason when the advert claim wait hits its bound. + static let advertContactSyncWaitTimedOutMessage = + "Timed out waiting for background contact sync" + + /// How to use a stored contact-sync watermark for one fetch round. + /// Does not rewrite storage — only decides the `since` filter for this round. + enum ContactWatermarkUse: Equatable, Sendable { + /// No successful contact sync stamp yet. + case none + /// Stamp is usable for incremental `since = watermark - 1`. + case incremental(UInt32) + /// Stamp is implausibly ahead of the reference. Fetch with `since == nil` + /// this round; leave the stored value alone until write-back of the new max. + case invalid(stored: UInt32) + } + + /// The `since` filter for an incremental contact fetch from a stored watermark. + private static func incrementalSince(watermark: UInt32) -> Date { + Date(timeIntervalSince1970: Double(watermark) - incrementalSinceOverlap) + } + + /// Decides whether a stored contact-sync watermark can drive incremental sync. + /// + /// Reference clock: **phone `Date`**. `syncDeviceTimeIfNeeded` holds the radio + /// within `deviceClockDriftTolerance` (5 s) of the phone on connect and sync + /// retry, so the phone is a local proxy for radio time without an extra + /// `getTime` BLE round on every contact sync and advert delta. + /// + /// When the stamp leads the reference by more than + /// `contactWatermarkPlausibilitySkew`, return `.invalid` so the caller fetches + /// with `since == nil` once. Never rewrite the stored stamp downward here — + /// phone-clock clamps pin below radio lastmods and loop full-table deltas. + nonisolated static func contactWatermarkUse( + fromLastContactSync raw: UInt32?, + referenceNow: Date = Date() + ) -> ContactWatermarkUse { + guard let raw, raw != noContactWatermark else { return .none } + let referenceSeconds = UInt32(referenceNow.timeIntervalSince1970) + let maxSkew = UInt32(contactWatermarkPlausibilitySkew) + let upperBound = referenceSeconds > UInt32.max - maxSkew + ? UInt32.max + : referenceSeconds + maxSkew + if raw > upperBound { + return .invalid(stored: raw) + } + return .incremental(raw) + } + // MARK: - Full Sync /// Performs full sync of contacts, channels, and messages from device. @@ -42,6 +114,8 @@ extension SyncCoordinator { channelSyncConfig: ChannelSyncConfig = .none, platformName: String = "unknown" ) async throws -> FullSyncResult { + try await waitForAdvertContactSync() + // Prevent concurrent syncs — actor-local flag avoids the TOCTOU window // that existed when guarding via `await state.isSyncing` guard !isSyncInProgress else { @@ -283,6 +357,8 @@ extension SyncCoordinator { ) async throws -> FullSyncResult { logger.info("Connection established for device \(radioID)") + try await waitForAdvertContactSync() + // 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. @@ -302,11 +378,25 @@ extension SyncCoordinator { startSuppressionWatchdog(notificationService: dependencies.notificationService) do { - // Defer advert-driven contact fetches during sync to avoid BLE contention + // Defer advert-driven contact sync during full sync to avoid BLE contention await dependencies.advertisementService.setSyncingContacts(true) - // 1. Wire message handlers first (before events can arrive) + // 1. Wire message handlers and advert delta-sync first (before events can arrive). + // Delta-sync wiring must not wait until after runFullSync: if the initial sync + // throws, performResync never rewires handlers and the handler would stay dead. await wireMessageHandlers(dependencies: dependencies, radioID: radioID) + let dataStore = dependencies.dataStore + let contactService = dependencies.contactService + await dependencies.advertisementService.setDeltaSyncHandler { [weak self] fullRefetch in + // A released coordinator means the connection is gone; retrying cannot help. + guard let self else { return .failed } + return await self.performAdvertContactSync( + fullRefetch: fullRefetch, + radioID: radioID, + dataStore: dataStore, + contactService: contactService + ) + } // Clean up legacy blocked sender messages still in DB from older app versions await deleteBlockedSenderMessages(radioID: radioID, dataStore: dependencies.dataStore) @@ -383,11 +473,20 @@ extension SyncCoordinator { "[Sync] onDisconnected called - syncState: \(String(describing: currentState)), hasEndedSyncActivity: \(hasEndedSyncActivity)" ) - // Safety net: clear sync guard flag on disconnect + // Safety net: clear sync guard flags on disconnect and wake any waiters so + // pull-to-refresh / full-sync cannot stay suspended after the connection drops. if isSyncInProgress { logger.warning("isSyncInProgress still true at disconnect — clearing as safety net") } isSyncInProgress = false + advertContactSyncActive = false + manualContactSyncActive = false + resumeAllAdvertSyncWaiters() + + // The next session must prove its own full contact fetch before delta sync runs. + fullContactSyncCompletedRadioID = nil + invalidWatermarkRecoveryRadioID = nil + invalidWatermarkRecoveryExhaustedLoggedRadioID = nil // Note: pending reactions are not cleared on disconnect - they persist for the app session // This handles temporary BLE disconnects without losing queued reactions @@ -433,31 +532,48 @@ extension SyncCoordinator { 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)))") + var ranInvalidWatermarkRecovery = false + let lastContactSync: Date? + if forceFullSync { + lastContactSync = nil + logger.notice( + "[Sync] Phase start: contacts (FULL sync, reason=forceFullSync) — local contacts not on device will be pruned" + ) } 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") + switch Self.contactWatermarkUse(fromLastContactSync: device?.lastContactSync) { + case .none: + lastContactSync = nil + logger.notice( + "[Sync] Phase start: contacts (FULL sync, reason=no watermark) — local contacts not on device will be pruned" + ) + case let .incremental(watermark): + lastContactSync = Self.incrementalSince(watermark: watermark) + case let .invalid(stored): + // Connect full-sync may prune (`since == nil`). Advert recovery must not — + // see performAdvertContactSync. Bound to one recovery per radio lifetime. + if invalidWatermarkRecoveryRadioID == radioID { + lastContactSync = Self.incrementalSince(watermark: stored) + logInvalidWatermarkRecoveryExhaustedIfNeeded(radioID: radioID, stored: stored) + } else { + ranInvalidWatermarkRecovery = true + lastContactSync = nil + logger.notice( + "[Sync] Phase start: contacts (FULL sync, reason=invalid watermark \(stored) exceeds phone reference + \(Int(Self.contactWatermarkPlausibilitySkew))s) — one-shot recovery, store not rewritten" + ) + } + } } - 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() + _ = try await syncContactsPhase( + radioID: radioID, + dataStore: dataStore, + contactService: contactService, + since: lastContactSync, + forceFullSync: forceFullSync + ) - // Update lastContactSync watermark for future incremental syncs - if contactResult.lastSyncTimestamp > 0 { - try await dataStore.updateDeviceLastContactSync( - radioID: radioID, - timestamp: contactResult.lastSyncTimestamp - ) + if ranInvalidWatermarkRecovery { + invalidWatermarkRecoveryRadioID = radioID } // Update RxLogService with contact public keys for direct message decryption @@ -611,6 +727,251 @@ extension SyncCoordinator { ) } + /// Logs once when invalid-watermark recovery is spent and rounds fall back to + /// the stored stamp (pathological residual far-future lastmod table). + private func logInvalidWatermarkRecoveryExhaustedIfNeeded(radioID: UUID, stored: UInt32) { + guard invalidWatermarkRecoveryExhaustedLoggedRadioID != radioID else { return } + invalidWatermarkRecoveryExhaustedLoggedRadioID = radioID + logger.notice( + "[Sync] Invalid watermark recovery exhausted for radio \(radioID.uuidString): stored \(stored) still implausible — using stored stamp; manual contact refresh required" + ) + } + + /// Runs contact sync and writes back the watermark when the result carries one. + /// Shared by full connect sync and advert-driven delta sync. + @discardableResult + private func syncContactsPhase( + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol, + since: Date?, + forceFullSync: Bool = false + ) async throws -> ContactSyncResult { + // Caller already logged prune-full cases; epoch-0 prune-free fetch is silent here. + if let watermark = since { + logger.info("[Sync] Phase start: contacts (incremental, watermark=\(watermark.formatted(.iso8601)))") + } + + let contactStart = ContinuousClock.now + let contactResult = try await contactService.syncContacts(radioID: radioID, since: since) + 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() + + if contactResult.lastSyncTimestamp > 0 { + try await dataStore.updateDeviceLastContactSync( + radioID: radioID, + timestamp: contactResult.lastSyncTimestamp + ) + } + if since == nil { + fullContactSyncCompletedRadioID = radioID + } + return contactResult + } + + /// Suspends while an advert-driven delta sync holds the claim. + /// + /// Background advert work must not turn a full sync, connection setup, or + /// channel-only retry into a silent skip, and must not interleave its + /// `ContactServiceEvent.syncProgress` events with a user-initiated refresh. + /// + /// Throws `CancellationError` when the calling task is cancelled, and + /// `SyncCoordinatorError.syncFailed` when `timeout` elapses while the claim + /// is still held. On timeout the caller must not proceed into the radio + /// pipeline (that races progress events) and must not return a silent skip + /// for user-initiated work (the wait exists to stop that). Surface an error + /// so the spinner stops and the user can retry. + func waitForAdvertContactSync( + timeout: Duration? = nil + ) async throws { + let bound = timeout + ?? advertContactSyncWaitTimeoutOverride + ?? Self.advertContactSyncWaitTimeout + let deadline = ContinuousClock.now + bound + + while isSyncInProgress, advertContactSyncActive { + try Task.checkCancellation() + + let remaining = deadline - ContinuousClock.now + if remaining <= .zero { + logger.warning("Timed out waiting for advert contact sync to release claim") + throw SyncCoordinatorError.syncFailed(Self.advertContactSyncWaitTimedOutMessage) + } + + let waiterID = nextAdvertSyncWaiterID + nextAdvertSyncWaiterID &+= 1 + try await waitForAdvertClaimRelease(id: waiterID, timeout: remaining) + } + } + + /// Waits out an advert claim, then marks a user-initiated contact refresh + /// active in the same actor turn so no delta can interleave between observe + /// and claim (separate wait and claim hops leave that window). + func claimManualContactSync( + timeout: Duration? = nil + ) async throws { + try await waitForAdvertContactSync(timeout: timeout) + manualContactSyncActive = true + } + + /// Marks a user-initiated contact refresh so advert delta sync returns `.busy`. + func setManualContactSyncActive(_ active: Bool) { + manualContactSyncActive = active + } + + /// Test hook: shortens the advert claim wait bound used by default parameters. + func setAdvertContactSyncWaitTimeoutOverride(_ timeout: Duration?) { + advertContactSyncWaitTimeoutOverride = timeout + } + + /// Suspends until the advert claim clears, the wait is cancelled, or `timeout` elapses. + private func waitForAdvertClaimRelease(id: UInt64, timeout: Duration) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await self.suspendUntilAdvertClaimReleased(id: id) + } + group.addTask { + try await Task.sleep(for: timeout) + throw SyncCoordinatorError.syncFailed(Self.advertContactSyncWaitTimedOutMessage) + } + // First finisher wins: claim release, cancel, or timeout. + try await group.next() + group.cancelAll() + } + } + + /// Parks one waiter until resume, cancel, or timeout-driven cancellation. + private func suspendUntilAdvertClaimReleased(id: UInt64) async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + // Claim may have cleared between the while check and registration. + if !(isSyncInProgress && advertContactSyncActive) { + continuation.resume() + return + } + advertSyncWaiters.append(AdvertSyncWaiter(id: id, continuation: continuation)) + } + } onCancel: { + Task { await self.finishAdvertSyncWaiter(id: id, error: CancellationError()) } + } + } + + /// Resumes one waiter if it is still registered (cancel/timeout path). + private func finishAdvertSyncWaiter(id: UInt64, error: Error) { + guard let index = advertSyncWaiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = advertSyncWaiters.remove(at: index) + waiter.continuation.resume(throwing: error) + } + + /// Resumes every advert-claim waiter. Used when the claim clears or on disconnect. + private func resumeAllAdvertSyncWaiters() { + let waiters = advertSyncWaiters + advertSyncWaiters = [] + for waiter in waiters { + waiter.continuation.resume() + } + } + + /// Advert-driven contact delta sync. Claims `isSyncInProgress` as advert-owned + /// so full sync waits rather than skips. + /// + /// Both modes need proof that a full contact fetch already succeeded for this + /// radio. Without it, writing a watermark here would make later full syncs + /// incremental and leave ghost contacts the device no longer has. An empty + /// radio stamps no watermark, so a completed full fetch grants entry on its + /// own and the round fetches from epoch zero until the first contact stamps one. + /// + /// - Parameter fullRefetch: When true, uses the epoch-0 prune-free full fetch. + /// When false, uses the stored watermark. + func performAdvertContactSync( + fullRefetch: Bool, + radioID: UUID, + dataStore: any PersistenceStoreProtocol, + contactService: some ContactServiceProtocol + ) async -> AdvertContactSyncOutcome { + guard !manualContactSyncActive else { + logger.info("Advert contact sync deferred because a manual contact refresh is active") + return .busy + } + guard !isSyncInProgress else { + logger.info("Advert contact sync deferred because sync is already active") + return .busy + } + isSyncInProgress = true + advertContactSyncActive = true + defer { + isSyncInProgress = false + advertContactSyncActive = false + resumeAllAdvertSyncWaiters() + } + + do { + let device = try await dataStore.fetchDevice(radioID: radioID) + let watermarkUse = Self.contactWatermarkUse(fromLastContactSync: device?.lastContactSync) + let hasCompletedFullSync = fullContactSyncCompletedRadioID == radioID + // `.invalid` still proves a prior stamp exists, so recovery may run. + let isReady = watermarkUse != .none || hasCompletedFullSync + guard isReady else { + logger.notice( + "[Sync] Advert contact sync skipped: no contact watermark yet (connect sync contacts phase has not succeeded)" + ) + return .notReady + } + + var ranInvalidWatermarkRecovery = false + let since: Date? + if fullRefetch { + logger.info("[Sync] Advert contact sync: prune-free full refetch") + since = Self.pruneFreeFullFetchSince + } else { + switch watermarkUse { + case .none: + logger.info("[Sync] Advert contact sync: prune-free full fetch (full sync found no contacts to stamp)") + since = Self.pruneFreeFullFetchSince + case let .incremental(watermark): + since = Self.incrementalSince(watermark: watermark) + case let .invalid(stored): + // Prune-free epoch-0 only. `since == nil` would delete local rows the + // device omits; connect full-sync may prune, background advert must not. + // One recovery full fetch per radio per coordinator lifetime; further + // rounds use the stored stamp so residual far-future lastmods cannot + // re-stream the whole table every debounce. + if invalidWatermarkRecoveryRadioID == radioID { + since = Self.incrementalSince(watermark: stored) + logInvalidWatermarkRecoveryExhaustedIfNeeded(radioID: radioID, stored: stored) + } else { + ranInvalidWatermarkRecovery = true + logger.notice( + "[Sync] Advert contact sync: invalid watermark \(stored) exceeds phone reference + \(Int(Self.contactWatermarkPlausibilitySkew))s — one-shot prune-free full recovery, store not rewritten" + ) + since = Self.pruneFreeFullFetchSince + } + } + } + + _ = try await syncContactsPhase( + radioID: radioID, + dataStore: dataStore, + contactService: contactService, + since: since + ) + if ranInvalidWatermarkRecovery { + invalidWatermarkRecoveryRadioID = radioID + } + return .synced + } catch { + logger.warning("Advert contact sync failed: \(error.localizedDescription)") + return .failed + } + } + /// Retries only unresolved channel indices without replaying contacts/messages. @discardableResult func retryChannels( @@ -622,6 +983,29 @@ extension SyncCoordinator { return ChannelSyncResult(channelsSynced: 0, errors: []) } + do { + try await waitForAdvertContactSync() + } catch is CancellationError { + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError(index: $0, errorType: .transportError, description: "Retry cancelled") + } + ) + } catch { + logger.warning("Channel-only retry timed out waiting for advert contact sync") + return ChannelSyncResult( + channelsSynced: 0, + errors: indices.map { + ChannelSyncError( + index: $0, + errorType: .circuitBreaker, + description: Self.advertContactSyncWaitTimedOutMessage + ) + } + ) + } + guard !isSyncInProgress else { logger.info("Channel-only retry skipped because sync is already active") return ChannelSyncResult( diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift index 63305c89d..aba4c68cd 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator.swift @@ -119,6 +119,46 @@ public actor SyncCoordinator { /// that existed when guarding via the `@MainActor`-isolated `state` property. var isSyncInProgress = false + /// True while `performAdvertContactSync` holds `isSyncInProgress`. Full sync, + /// connection setup, and channel-only retry wait for this claim instead of skipping. + var advertContactSyncActive = false + + /// True while a user-initiated contact refresh holds the radio pipeline. + /// Advert delta sync returns `.busy` so it retries on the debounce rather + /// than racing progress events with pull-to-refresh. + var manualContactSyncActive = false + + /// Continuations held by `waitForAdvertContactSync` until the advert claim clears. + var advertSyncWaiters: [AdvertSyncWaiter] = [] + + /// Monotonic id for advert-sync waiters so cancel/timeout can resume one waiter. + var nextAdvertSyncWaiterID: UInt64 = 0 + + /// Optional override for the advert claim wait bound. Tests set a short value; + /// production leaves this nil so `advertContactSyncWaitTimeout` applies. + var advertContactSyncWaitTimeoutOverride: Duration? + + /// One suspended waiter for the advert contact-sync claim. + struct AdvertSyncWaiter { + let id: UInt64 + let continuation: CheckedContinuation + } + + /// Radio whose full contact fetch (`since == nil`) last completed. An empty + /// contact table stamps no watermark, so the zero sentinel alone cannot separate + /// "no full sync yet" from "nothing to stamp". Advert delta sync needs that + /// difference to run at all. + var fullContactSyncCompletedRadioID: UUID? + + /// Radio that already spent its one invalid-watermark recovery full fetch this + /// coordinator lifetime. Bounds residual far-future lastmod tables so advert + /// delta cannot re-stream the whole contact table every debounce forever. + /// Manual pull-to-refresh and `forceFullSync` do not consult this latch. + var invalidWatermarkRecoveryRadioID: UUID? + + /// Radio for which the exhausted-recovery notice was already logged once. + var invalidWatermarkRecoveryExhaustedLoggedRadioID: UUID? + /// Cached blocked names (contacts + channel senders) for O(1) lookup in message handlers private var blockedNames: Set = [] @@ -364,7 +404,7 @@ public actor SyncCoordinator { // MARK: - Timestamp Correction /// Maximum acceptable time in the future for a sender timestamp (5 minutes) - private static let timestampToleranceFuture: TimeInterval = 5 * 60 + 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 diff --git a/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift index 7357b5c30..52863a834 100644 --- a/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift @@ -6,8 +6,8 @@ import Testing // MARK: - Helpers private enum AdvertisementServiceTestError: Error { - case getContactFailed case deadlineExceeded(String) + case storeUnavailable } private func makePublicKey(seed: UInt8) -> Data { @@ -19,7 +19,11 @@ private func makeMeshContact( name: String = "Node", type: ContactType = .chat, outPathLength: UInt8 = 0, - outPath: Data = Data() + outPath: Data = Data(), + latitude: Double = 0, + longitude: Double = 0, + lastAdvertTimestamp: Date = Date(timeIntervalSince1970: 1_700_000_000), + lastModified: Date = Date(timeIntervalSince1970: 1_700_000_100) ) -> MeshContact { MeshContact( id: publicKey.hexString, @@ -29,17 +33,21 @@ private func makeMeshContact( outPathLength: outPathLength, outPath: outPath, advertisedName: name, - lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), - latitude: 0, - longitude: 0, - lastModified: Date(timeIntervalSince1970: 1_700_000_100) + lastAdvertisement: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified ) } private func makeContactFrame( publicKey: Data, name: String = "LocalContact", - type: ContactType = .chat + type: ContactType = .chat, + latitude: Double = 0, + longitude: Double = 0, + lastAdvertTimestamp: UInt32 = 1_700_000_000, + lastModified: UInt32 = 1_700_000_100 ) -> ContactFrame { ContactFrame( publicKey: publicKey, @@ -48,10 +56,10 @@ private func makeContactFrame( outPathLength: 0, outPath: Data(), name: name, - lastAdvertTimestamp: 1_700_000_000, - latitude: 0, - longitude: 0, - lastModified: 1_700_000_100 + lastAdvertTimestamp: lastAdvertTimestamp, + latitude: latitude, + longitude: longitude, + lastModified: lastModified ) } @@ -70,6 +78,102 @@ private func waitUntil( return await predicate() } +private actor HandlerRecorder { + private(set) var calls: [Bool] = [] + private var results: [AdvertContactSyncOutcome] = [] + private var persistFrames: [ContactFrame] = [] + private let store: any PersistenceStoreProtocol + private let radioID: UUID + + init(store: any PersistenceStoreProtocol, radioID: UUID) { + self.store = store + self.radioID = radioID + } + + func enqueueResult(_ outcome: AdvertContactSyncOutcome) { + results.append(outcome) + } + + func enqueuePersist(_ frame: ContactFrame) { + persistFrames.append(frame) + } + + func handle(fullRefetch: Bool) async -> AdvertContactSyncOutcome { + calls.append(fullRefetch) + let outcome = results.isEmpty ? AdvertContactSyncOutcome.synced : results.removeFirst() + // Only persist on success so a failed call cannot leave a row that + // masks a missing re-merge of pending keys. + if outcome == .synced, !persistFrames.isEmpty { + let frame = persistFrames.removeFirst() + _ = try? await store.saveContact(radioID: radioID, from: frame) + } + return outcome + } + + var callCount: Int { + calls.count + } + + var fullRefetchFlags: [Bool] { + calls + } +} + +private actor EventCounter { + private(set) var newContactCount = 0 + private(set) var contactUpdatedCount = 0 + private(set) var conversationsChangedCount = 0 + private(set) var adoptedContactIDs: [UUID] = [] + + func note(_ event: AdvertisementEvent) { + switch event { + case .newContactDiscovered: + newContactCount += 1 + case .contactUpdated: + contactUpdatedCount += 1 + case .conversationsChanged: + conversationsChangedCount += 1 + case let .orphanDirectMessagesAdopted(contactIDs): + adoptedContactIDs.append(contentsOf: contactIDs) + default: + break + } + } +} + +/// Records `fullRefetch` flags from a custom delta-sync handler. +private actor CallFlagRecorder { + private(set) var flags: [Bool] = [] + + func note(_ fullRefetch: Bool) { + flags.append(fullRefetch) + } +} + +/// Fails every round up to the cap, and on the final failing round records a +/// fresh advert key mid-flight — reproducing an 0x80 that lands while the last +/// failing handler is awaited. Rounds past the cap succeed so the fresh key drains. +private actor CapRoundInjector { + private let service: AdvertisementService + private let freshKey: Data + private let cap: Int + private(set) var calls = 0 + + init(service: AdvertisementService, freshKey: Data, cap: Int) { + self.service = service + self.freshKey = freshKey + self.cap = cap + } + + func handle() async -> AdvertContactSyncOutcome { + calls += 1 + if calls == cap { + await service.recordPendingAdvertKey(freshKey) + } + return calls > cap ? .synced : .failed + } +} + // MARK: - Suite @Suite("AdvertisementService Tests", .serialized) @@ -86,9 +190,18 @@ struct AdvertisementServiceTests { private func makeService( session: MockMeshCoreSession, - store: any PersistenceStoreProtocol + store: any PersistenceStoreProtocol, + advertSyncDebounce: Duration = .zero, + advertSyncMinInterval: Duration = .zero, + advertSyncBusyBackoff: Duration = .zero ) -> AdvertisementService { - AdvertisementService(session: session, dataStore: store) + AdvertisementService( + session: session, + dataStore: store, + advertSyncDebounce: advertSyncDebounce, + advertSyncMinInterval: advertSyncMinInterval, + advertSyncBusyBackoff: advertSyncBusyBackoff + ) } private func startMonitoring(_ service: AdvertisementService, session: MockMeshCoreSession) async { @@ -99,705 +212,2088 @@ struct AdvertisementServiceTests { #expect(subscribed) } - // MARK: - Drain-loop non-blocking + private func installHandler( + _ service: AdvertisementService, + recorder: HandlerRecorder + ) async { + await service.setDeltaSyncHandler { fullRefetch in + await recorder.handle(fullRefetch: fullRefetch) + } + } + + // MARK: - Rollback cascade safety @Test - func `held getContact for A does not block contactDeleted for B`() async throws { + func `pathUpdate cancel after save does not cascade-delete messages`() async throws { + // A contact re-saved mid-round after 0x8F must not cascade-wipe a DM + // that landed before rollback. let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let keyA = makePublicKey(seed: 0xA1) - let keyB = makePublicKey(seed: 0xB2) - - // Seed contact B locally so 0x8F can delete it. - let frameB = makeContactFrame(publicKey: keyB, name: "ContactB") - let contactBID = try await store.saveContact(radioID: radioID, from: frameB).id - - await session.setStubbedContact(makeMeshContact(publicKey: keyA, name: "ContactA"), for: keyA) - await session.holdNextGetContact(for: keyA) + let key = makePublicKey(seed: 0xD4) + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + let saved = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "HasMessages") + ) + if let contactID = saved?.id { + try? await store.saveMessage( + MessageDTO.testDirectMessage(radioID: radioID, contactID: contactID, text: "keep me") + ) + } + return .synced + } await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) - // Unknown-key advert for A parks getContact. - await session.yieldEvent(.advertisement(publicKey: keyA)) - let held = await waitUntil { - await session.isGetContactHeld(for: keyA) - } - #expect(held, "getContact for A should be held open") + // 0x8F while commit is held: no local row yet, so only the rollback key is set. + await session.yieldEvent(.contactDeleted(publicKey: key)) + try? await Task.sleep(for: .milliseconds(40)) + await hold.release() - // While A is held, 0x8F for B must still be processed (drain is non-blocking). - await session.yieldEvent(.contactDeleted(publicKey: keyB)) + let roundDone = await waitUntil { await service.deltaSyncTask == nil } + #expect(roundDone) - let deleted = await waitUntil(timeout: .seconds(1)) { - let contact = try? await store.fetchContact(id: contactBID) - return contact == nil - } + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + let messages = try await store.fetchMessages(contactID: contact.id, limit: 50, offset: 0) + #expect(messages.count == 1, "messages must not be cascade-deleted on rollback") - await session.releaseGetContact(for: keyA) await service.stopEventMonitoring() - - #expect(deleted, "B must be deleted while A's getContact is still airborne") } - // MARK: - Ghost-contact cancel (same key, no local row) + // MARK: - Coalescing @Test - func `0x8F for unknown airborne key prevents ghost save`() async throws { + func `burst of adverts coalesces to one handler call`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + // Hold the first handler call so later adverts accumulate in pendingAdvertKeys + // instead of each completing a separate drain under .zero durations. + let hold = HandlerHold() + await service.setDeltaSyncHandler { fullRefetch in + let isFirst = await recorder.callCount == 0 + let result = await recorder.handle(fullRefetch: fullRefetch) + if isFirst { + await hold.waitUntilReleased() + } + return result + } - let keyA = makePublicKey(seed: 0xC3) - await session.setStubbedContact(makeMeshContact(publicKey: keyA, name: "Ghost"), for: keyA) - await session.holdNextGetContact(for: keyA) + let keys = (0..<5).map { makePublicKey(seed: UInt8(0xA0 &+ $0)) } + for key in keys { + let frame = makeContactFrame(publicKey: key, name: "K\(key.prefix(1).hexString)") + _ = try await store.saveContact(radioID: radioID, from: frame) + } await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keyA)) - - let held = await waitUntil { await session.isGetContactHeld(for: keyA) } + await session.yieldEvent(.advertisement(publicKey: keys[0])) + let held = await waitUntil { await hold.isWaiting } #expect(held) + #expect(await recorder.callCount == 1) - // 0x8F with no local Contact row — cancel must still be recorded. - await session.yieldEvent(.contactDeleted(publicKey: keyA)) - await session.releaseGetContact(for: keyA) - - // Wait until the held getContact has actually returned (not just been appended at hold entry). - let fetchReturned = await waitUntil { - await !session.isGetContactHeld(for: keyA) - } - #expect(fetchReturned, "getContact must complete after release") - - // Bound "commit finished and still nil": contact stays absent across several polls. - var sawContact = false - for _ in 0..<5 { - try? await Task.sleep(for: .milliseconds(20)) - if await (try? store.fetchContact(radioID: radioID, publicKey: keyA)) != nil { - sawContact = true - break - } + for key in keys.dropFirst() { + await session.yieldEvent(.advertisement(publicKey: key)) } + // Let the event loop record the remaining keys while the first drain is held. + try? await Task.sleep(for: .milliseconds(40)) + #expect(await recorder.callCount == 1, "mid-hold adverts must not start a parallel handler") + await hold.release() + + // Mid-hold keys schedule exactly one follow-up pass after release. + let secondPass = await waitUntil { await recorder.callCount == 2 } + #expect(secondPass, "keys recorded mid-hold should schedule one second pass") + try? await Task.sleep(for: .milliseconds(40)) await service.stopEventMonitoring() - #expect(!sawContact, "cancelled fetch must not insert a ghost contact") + + #expect(await recorder.callCount == 2) + #expect(await session.getContactPublicKeys.isEmpty) } - // MARK: - Cancel after save (isNew false) + // MARK: - Known contact 0x80 @Test - func `pathUpdate cancel after save does not cascade-delete messages`() async throws { - // Hold saveContact after getContact returns so cancel lands post-save - // (isNew false must not cascade-delete messages). - let store = MockPersistenceStore() + func `known contact advert bumps lastHeard without getContact`() async throws { + let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) - let key = makePublicKey(seed: 0xD4) - let frame = makeContactFrame(publicKey: key, name: "HasMessages") - let contactID = try await store.saveContact(radioID: radioID, from: frame).id - try await store.saveMessage( - MessageDTO.testDirectMessage(radioID: radioID, contactID: contactID, text: "keep me") - ) - - await session.setStubbedContact( - makeMeshContact(publicKey: key, name: "HasMessages", outPathLength: 1, outPath: Data([0xAA])), - for: key - ) - await store.holdNextSaveContact() + let key = makePublicKey(seed: 0xB1) + let frame = makeContactFrame(publicKey: key, name: "Known") + _ = try await store.saveContact(radioID: radioID, from: frame) + _ = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) + let before = Date().addingTimeInterval(-1) await startMonitoring(service, session: session) - await session.yieldEvent(.pathUpdate(publicKey: key)) - - let saveHeld = await waitUntil { await store.isSaveContactHeld } - #expect(saveHeld, "saveContact must be held so cancel can land after getContact") - - // Teardown cancels while save is airborne; isNew false must not deleteContact. - let completedBeforeRelease = await store.saveContactCompletedCount - await service.stopEventMonitoring() - await store.releaseSaveContact() + await session.yieldEvent(.advertisement(publicKey: key)) - // Wait for save to finish, then multi-poll so a late deleteContact cannot - // race past a single empty-deleted snapshot. - let saveFinished = await waitUntil { - await store.saveContactCompletedCount > completedBeforeRelease + let heard = await waitUntil { + let contact = try? await store.fetchContact(radioID: radioID, publicKey: key) + return (contact?.lastHeardTimestamp ?? 0) > 0 } - #expect(saveFinished, "saveContact must complete after release") + #expect(heard) - var sawCascadeDefect = false - for _ in 0..<5 { - try? await Task.sleep(for: .milliseconds(20)) - let contact = try? await store.fetchContact(id: contactID) - let messages = await (try? store.fetchMessages(contactID: contactID, limit: 50, offset: 0)) ?? [] - let deleted = await store.deletedContactIDs - if contact == nil || messages.count != 1 || !deleted.isEmpty { - sawCascadeDefect = true - break - } - } - #expect(!sawCascadeDefect, "contact, messages, and empty deletes must hold across the settle window") + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + #expect((contact.lastHeardTimestamp ?? 0) >= UInt32(before.timeIntervalSince1970)) - let messages = try await store.fetchMessages(contactID: contactID, limit: 50, offset: 0) - let contact = try await store.fetchContact(id: contactID) - #expect(contact != nil, "existing contact must survive cancelled path-update upsert") - #expect(messages.count == 1, "messages must not be cascade-deleted on cancelled upsert") - #expect(await store.deletedContactIDs.isEmpty, "isNew false must not invoke deleteContact") + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + let node = try #require(nodes.first { $0.publicKey == key }) + #expect(node.lastHeard >= before) + + let handlerRan = await waitUntil { await recorder.callCount >= 1 } + #expect(handlerRan) + #expect(await session.getContactPublicKeys.isEmpty) + await service.stopEventMonitoring() } - // MARK: - Reason upgrade + // MARK: - Unknown key 0x80 @Test - func `pathUpdate upgraded by advert saves DiscoveredNode`() async throws { + func `unknown key advert yields newContactDiscovered once after handler persists`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let key = makePublicKey(seed: 0xC2) + await recorder.enqueuePersist( + makeContactFrame(publicKey: key, name: "NewNode", latitude: 10, longitude: 20) + ) + await installHandler(service, recorder: recorder) - let key = makePublicKey(seed: 0xE5) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "Upgraded"), for: key) - await session.holdNextGetContact(for: key) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } await startMonitoring(service, session: session) - await session.yieldEvent(.pathUpdate(publicKey: key)) + await session.yieldEvent(.advertisement(publicKey: key)) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) + let discovered = await waitUntil { + await counter.newContactCount >= 1 + } + #expect(discovered) - // Upgrade reason while airborne. Yield and allow the drain loop to process - // the advert before releasing getContact so the upgrade is visible to the worker. + // Second advert for the same key must not re-notify as new. + await recorder.enqueuePersist( + makeContactFrame(publicKey: key, name: "NewNode", latitude: 11, longitude: 21) + ) await session.yieldEvent(.advertisement(publicKey: key)) + let secondHandler = await waitUntil { await recorder.callCount >= 2 } + #expect(secondHandler) try? await Task.sleep(for: .milliseconds(50)) - await session.releaseGetContact(for: key) - let nodesAppeared = await waitUntil { - let nodes = await (try? store.fetchDiscoveredNodes(radioID: radioID)) ?? [] - return nodes.contains { $0.publicKey == key } - } await service.stopEventMonitoring() - #expect(nodesAppeared, "advert reason upgrade must upsert DiscoveredNode") + service.finishEvents() + _ = await listener.result + + #expect(await counter.newContactCount == 1) + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + #expect(contact.name == "NewNode") } + // MARK: - 0x8A parity + @Test - func `advert during pathUpdate saveContact upgrades to DiscoveredNode`() async { - // Reason is pathUpdate at switch time; advert upgrades the queue entry while - // pathUpdate's saveContact is airborne. commitPathUpdateFetch never upserts - // DiscoveredNode, so Discover visibility depends on the upgrade path. - let store = MockPersistenceStore() + func `0x8A then 0x80 for same key notifies from each path`() async throws { + let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0xD3) + let mesh = makeMeshContact(publicKey: key, name: "Manual") - let key = makePublicKey(seed: 0xE6) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "MidCommit"), for: key) - await store.holdNextSaveContact() + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } await startMonitoring(service, session: session) - await session.yieldEvent(.pathUpdate(publicKey: key)) + await session.yieldEvent(.newContact(mesh)) - let saveHeld = await waitUntil { await store.isSaveContactHeld } - #expect(saveHeld, "pathUpdate saveContact must be held so advert can upgrade mid-commit") + let from8A = await waitUntil { await counter.newContactCount >= 1 } + #expect(from8A) - // Unknown-key advert upgrades the queue entry while pathUpdate save is airborne. - // PathUpdate inserts first, so the advert commit sees isNew false and emits - // .contactUpdated rather than .newContactDiscovered. + // 0x8A leaves a Discover row but no Contact row. The 0x80 delta round's + // pre-round snapshot therefore lacks the key; after the handler persists the + // Contact row it lands in insertedKeys and is announced separately from 0x8A. + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "Manual")) await session.yieldEvent(.advertisement(publicKey: key)) - try? await Task.sleep(for: .milliseconds(50)) - await store.releaseSaveContact() - let nodesAppeared = await waitUntil { - let nodes = await (try? store.fetchDiscoveredNodes(radioID: radioID)) ?? [] - return nodes.contains { $0.publicKey == key } - } + // 0x8A yielded the first .contactUpdated; the second one lands after reconcile. + let reconciled = await waitUntil { await counter.contactUpdatedCount >= 2 } + #expect(reconciled) + try? await Task.sleep(for: .milliseconds(30)) + await service.stopEventMonitoring() - #expect(nodesAppeared, "mid-commit advert upgrade must upsert DiscoveredNode") + service.finishEvents() + _ = await listener.result + + #expect(await counter.newContactCount == 2) + #expect(await recorder.callCount == 1) } - // MARK: - One drainer / re-entrant barrier + // MARK: - Path update @Test - func `concurrent setSyncingContacts false performs one fetch per key`() async throws { + func `pathUpdate only triggers handler without Discover row`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) - let key = makePublicKey(seed: 0xF6) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "Once"), for: key) - await session.holdNextGetContact(for: key) + let key = makePublicKey(seed: 0xE4) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } - await service.setSyncingContacts(true) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: key)) + await session.yieldEvent(.pathUpdate(publicKey: key)) - // Two concurrent barrier callers. - async let barrier1: Void = service.setSyncingContacts(false) - async let barrier2: Void = service.setSyncingContacts(false) + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) + let updated = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(updated) - await session.releaseGetContact(for: key) - _ = await (barrier1, barrier2) + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.isEmpty) + #expect(await session.getContactPublicKeys.isEmpty) - let fetchCount = await session.getContactPublicKeys.filter { $0 == key }.count await service.stopEventMonitoring() - #expect(fetchCount == 1, "one drainer must fetch each key once across re-entrant barriers") + service.finishEvents() + _ = await listener.result } - // MARK: - Cancel-guarded removal (re-enqueue while airborne) - @Test - func `0x8F then re-advert while airborne fetches again and saves`() async throws { + func `pathUpdate for known contact escalates when incremental leaves lastModified unchanged`() async throws { + // Radio RTC reset / clock step-back can stamp path lastmod at or below the + // stored watermark. Incremental GET_CONTACTS then returns nothing; known + // contacts never enter escalateMissingUnknownKeys. Without a post-round + // path check the out-path stays stale forever. let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let key = makePublicKey(seed: 0x17) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "ReAdded"), for: key) - await session.holdNextGetContact(for: key) + let key = makePublicKey(seed: 0xA1) + let oldLastMod: UInt32 = 1_700_000_100 + let oldPath = Data([0x01, 0x02]) + let newPath = Data([0x03, 0x04]) + let newLastMod: UInt32 = oldLastMod + 50 + _ = try await store.saveContact( + radioID: radioID, + from: ContactFrame( + publicKey: key, + type: .repeater, + flags: 0, + outPathLength: 2, + outPath: oldPath, + name: "Relay", + lastAdvertTimestamp: 1_700_000_000, + latitude: 10.0, + longitude: 20.0, + lastModified: oldLastMod + ) + ) + + let calls = CallFlagRecorder() + await service.setDeltaSyncHandler { fullRefetch in + await calls.note(fullRefetch) + // Incremental models an empty watermark filter (no row written). + // Only the escalated full refetch delivers the path update. + if fullRefetch { + _ = try? await store.saveContact( + radioID: radioID, + from: ContactFrame( + publicKey: key, + type: .repeater, + flags: 0, + outPathLength: 2, + outPath: newPath, + name: "Relay", + lastAdvertTimestamp: 1_700_000_000, + latitude: 10.5, + longitude: 20.5, + lastModified: newLastMod + ) + ) + } + return .synced + } await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: key)) + await session.yieldEvent(.pathUpdate(publicKey: key)) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) + let escalated = await waitUntil(timeout: .seconds(3)) { + let flags = await calls.flags + return flags.count >= 2 && flags.contains(true) + } + #expect( + escalated, + "when incremental leaves lastModified unchanged, path must escalate to full refetch" + ) + #expect(await calls.flags.first == false) + #expect(await session.getContactPublicKeys.isEmpty, "must not reinstate per-key getContact") - // Cancel airborne fetch, then radio re-adds and advert re-enqueues before release. - // A cancelled fetch must leave the newer entry so the worker fetches again. - await session.yieldEvent(.contactDeleted(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) - await session.yieldEvent(.advertisement(publicKey: key)) - try? await Task.sleep(for: .milliseconds(50)) - await session.releaseGetContact(for: key) + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact?.outPath == newPath) + #expect(contact?.lastModified == newLastMod) + #expect(contact?.latitude == 10.5) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil - } - let fetchCount = await session.getContactPublicKeys.filter { $0 == key }.count await service.stopEventMonitoring() - #expect(saved, "re-enqueued key must be saved after second fetch") - #expect(fetchCount == 2, "cancelled fetch must not strand the re-enqueued key") } @Test - func `0x8F re-advert then getContact throw does not strand re-enqueued key`() async throws { + func `pathUpdate for known contact does not escalate when incremental refreshes lastModified`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0xA2) + let oldLastMod: UInt32 = 1_700_000_100 + let newLastMod: UInt32 = oldLastMod + 10 + let newPath = Data([0xAA, 0xBB]) + _ = try await store.saveContact( + radioID: radioID, + from: ContactFrame( + publicKey: key, + type: .repeater, + flags: 0, + outPathLength: 1, + outPath: Data([0x11]), + name: "Relay", + lastAdvertTimestamp: 1_700_000_000, + latitude: 1.0, + longitude: 2.0, + lastModified: oldLastMod + ) + ) - let key = makePublicKey(seed: 0x18) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "ThrowThenOK"), for: key) - await session.holdNextGetContact(for: key) + await recorder.enqueueResult(.synced) + await recorder.enqueuePersist( + ContactFrame( + publicKey: key, + type: .repeater, + flags: 0, + outPathLength: 2, + outPath: newPath, + name: "Relay", + lastAdvertTimestamp: 1_700_000_000, + latitude: 1.0, + longitude: 2.0, + lastModified: newLastMod + ) + ) + await installHandler(service, recorder: recorder) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: key)) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) - - await session.yieldEvent(.contactDeleted(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) - await session.yieldEvent(.advertisement(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) + await session.yieldEvent(.pathUpdate(publicKey: key)) - // First release throws while cancelled — must not wipe the re-enqueued entry. - await session.setGetContactError(AdvertisementServiceTestError.getContactFailed, for: key) - await session.holdNextGetContact(for: key) - await session.releaseGetContact(for: key) + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + // Allow a second round if one were incorrectly scheduled. + try? await Task.sleep(for: .milliseconds(80)) + #expect(await recorder.callCount == 1, "successful path delivery must not escalate") + #expect(await recorder.fullRefetchFlags == [false]) + #expect(await !service.escalateToFullRefetch) - let heldAgain = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(heldAgain, "re-enqueued key must be fetched again after cancelled throw") - await session.setGetContactError(nil, for: key) - await session.releaseGetContact(for: key) + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact?.outPath == newPath) + #expect(contact?.lastModified == newLastMod) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil - } await service.stopEventMonitoring() - #expect(saved, "throw after cancel must not strand the re-enqueued key") } + // MARK: - Failure re-merge + @Test - func `0x8F re-advert then getContact nil does not strand re-enqueued key`() async throws { + func `handler failure remerges keys and retries`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let key = makePublicKey(seed: 0xF5) + // Unknown key: first fail leaves no row; second success persists + reconcile notifies. + // Proves re-merge kept the key — empty drained would skip Discover/newContact. + await recorder.enqueueResult(.failed) + await recorder.enqueueResult(.synced) + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "Retry")) + await installHandler(service, recorder: recorder) - let key = makePublicKey(seed: 0x19) - let contact = makeMeshContact(publicKey: key, name: "NilThenOK") - await session.setStubbedContact(contact, for: key) - await session.holdNextGetContact(for: key) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } await startMonitoring(service, session: session) await session.yieldEvent(.advertisement(publicKey: key)) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) - await session.yieldEvent(.contactDeleted(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) - await session.yieldEvent(.advertisement(publicKey: key)) - try? await Task.sleep(for: .milliseconds(30)) + let retried = await waitUntil { await recorder.callCount >= 2 } + #expect(retried) - // First release returns nil while cancelled — must not wipe the re-enqueued entry. - await session.setStubbedContact(nil, for: key) - await session.holdNextGetContact(for: key) - await session.releaseGetContact(for: key) + let discovered = await waitUntil { await counter.newContactCount >= 1 } + #expect(discovered, "re-merged key must reach reconcile after successful retry") - let heldAgain = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(heldAgain, "re-enqueued key must be fetched again after cancelled nil") - await session.setStubbedContact(contact, for: key) - await session.releaseGetContact(for: key) + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact != nil) + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.contains { $0.publicKey == key }) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil - } await service.stopEventMonitoring() - #expect(saved, "nil after cancel must not strand the re-enqueued key") + service.finishEvents() + _ = await listener.result } - // MARK: - Throw drops and continues + barrier return + // MARK: - Syncing deferral @Test - func `getContact throw drops entry and continues to next key`() async throws { + func `isSyncingContacts defers handler until cleared`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) - let keyFail = makePublicKey(seed: 0x28) - let keyOK = makePublicKey(seed: 0x29) - await session.setGetContactError(AdvertisementServiceTestError.getContactFailed, for: keyFail) - await session.setStubbedContact(makeMeshContact(publicKey: keyOK, name: "OK"), for: keyOK) + let key = makePublicKey(seed: 0x16) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Deferred") + ) await service.setSyncingContacts(true) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keyFail)) - await session.yieldEvent(.advertisement(publicKey: keyOK)) + await session.yieldEvent(.advertisement(publicKey: key)) - // Barrier must return even when one key throws. - await service.setSyncingContacts(false) + try? await Task.sleep(for: .milliseconds(50)) + #expect(await recorder.callCount == 0) - let okSaved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: keyOK)) != nil - } - let failSaved = try await store.fetchContact(radioID: radioID, publicKey: keyFail) + await service.setSyncingContacts(false) + let ran = await waitUntil { await recorder.callCount >= 1 } await service.stopEventMonitoring() - #expect(okSaved) - #expect(failSaved == nil) + #expect(ran) } + // MARK: - Teardown mid-handler + @Test - func `all keys throw ends pass without spin or residual contacts`() async throws { + func `teardown mid-handler prevents reconcile writes`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let keys = [makePublicKey(seed: 0x2A), makePublicKey(seed: 0x2B), makePublicKey(seed: 0x2C)] - for key in keys { - await session.setGetContactError(AdvertisementServiceTestError.getContactFailed, for: key) + let key = makePublicKey(seed: 0x27) + let hold = HandlerHold() + + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // Persist as a successful sync would. + _ = try? await store.saveContact( + radioID: radioID, + from: makeContactFrame(publicKey: key, name: "Late") + ) + return .synced } - await service.setSyncingContacts(true) await startMonitoring(service, session: session) - for key in keys { - await session.yieldEvent(.advertisement(publicKey: key)) - } - - let barrierState = BarrierFlag() - let barrierTask = Task { - await service.setSyncingContacts(false) - await barrierState.markDone() - } + await session.yieldEvent(.advertisement(publicKey: key)) - let barrierReturned = await waitUntil(timeout: .seconds(2)) { - await barrierState.isDone - } - #expect(barrierReturned, "barrier must return when every key throws") - _ = await barrierTask.result + let entered = await waitUntil { await hold.isWaiting } + #expect(entered) - for key in keys { - let count = await session.getContactPublicKeys.filter { $0 == key }.count - #expect(count == 1, "each throwing key fetched once") - let contact = try await store.fetchContact(radioID: radioID, publicKey: key) - #expect(contact == nil) - } await service.stopEventMonitoring() + await hold.release() + + // Settle: Contact may have been written by the held handler body, but + // reconcile must not create a Discover row after teardown. + try? await Task.sleep(for: .milliseconds(80)) + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.isEmpty, "reconcile must not land after stopEventMonitoring") } - // MARK: - Bounded barrier + // MARK: - Escalation @Test - func `live enqueue after pass starts does not extend barrier`() async throws { + func `unknown key missing after success escalates to fullRefetch once`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - - let keySnap = makePublicKey(seed: 0x3A) - let keyLate = makePublicKey(seed: 0x3B) - await session.setStubbedContact(makeMeshContact(publicKey: keySnap, name: "Snap"), for: keySnap) - await session.setStubbedContact(makeMeshContact(publicKey: keyLate, name: "Late"), for: keyLate) - await session.holdNextGetContact(for: keySnap) - - await service.setSyncingContacts(true) + let recorder = HandlerRecorder(store: store, radioID: radioID) + // Incremental success (no row) → escalate; fullRefetch fails → flag restored; + // next call still fullRefetch; final fullRefetch success drops still-missing key. + await recorder.enqueueResult(.synced) + await recorder.enqueueResult(.failed) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x38) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keySnap)) - - // Start barrier (pass snapshots keySnap). - let barrierState = BarrierFlag() - let barrierTask = Task { - await service.setSyncingContacts(false) - await barrierState.markDone() - } - - let held = await waitUntil { await session.isGetContactHeld(for: keySnap) } - #expect(held) - - // Live enqueue after pass starts. - await session.yieldEvent(.advertisement(publicKey: keyLate)) - await session.releaseGetContact(for: keySnap) - - // Barrier must return without waiting for keyLate's fetch to complete the pass. - let barrierReturned = await waitUntil(timeout: .seconds(2)) { - await barrierState.isDone - } - #expect(barrierReturned, "barrier must not be extended by mid-pass enqueues") - _ = await barrierTask.result + await session.yieldEvent(.advertisement(publicKey: key)) - // Late key is still drained by the same worker afterwards. - let lateSaved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: keyLate)) != nil - } + let threeCalls = await waitUntil { await recorder.callCount >= 3 } await service.stopEventMonitoring() - #expect(lateSaved) + #expect(threeCalls) + + let flags = await recorder.fullRefetchFlags + #expect(flags.count == 3) + #expect(flags[0] == false, "first pass is incremental") + #expect(flags[1] == true, "escalated full refetch") + #expect(flags[2] == true, "failed full refetch must restore escalate flag") + // Still-missing key is dropped after escalated success — no fourth call. + try? await Task.sleep(for: .milliseconds(50)) + #expect(await recorder.callCount == 3) } + // MARK: - Nil handler keeps pending + @Test - func `late barrier during subsequent pass does not await further live enqueues`() async throws { + func `nil handler leaves keys pending for later install`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) - let keySnap = makePublicKey(seed: 0x3C) - let keyLate = makePublicKey(seed: 0x3D) - let keyExtra = makePublicKey(seed: 0x3E) - await session.setStubbedContact(makeMeshContact(publicKey: keySnap, name: "Snap"), for: keySnap) - await session.setStubbedContact(makeMeshContact(publicKey: keyLate, name: "Late"), for: keyLate) - await session.setStubbedContact(makeMeshContact(publicKey: keyExtra, name: "Extra"), for: keyExtra) - await session.holdNextGetContact(for: keySnap) + let key = makePublicKey(seed: 0x61) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Pending") + ) - await service.setSyncingContacts(true) + // No handler yet — schedule runs, finds nil, leaves keys pending. await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keySnap)) - - // First barrier: awaits snapshotted pass over keySnap only. - let firstBarrier = BarrierFlag() - let firstTask = Task { - await service.setSyncingContacts(false) - await firstBarrier.markDone() - } - - let snapHeld = await waitUntil { await session.isGetContactHeld(for: keySnap) } - #expect(snapHeld) - - // Late key arrives during the first pass; hold so the next pass parks on it. - await session.holdNextGetContact(for: keyLate) - await session.yieldEvent(.advertisement(publicKey: keyLate)) - await session.releaseGetContact(for: keySnap) + await session.yieldEvent(.advertisement(publicKey: key)) + try? await Task.sleep(for: .milliseconds(40)) + #expect(await recorder.callCount == 0) - let firstReturned = await waitUntil(timeout: .seconds(2)) { await firstBarrier.isDone } - #expect(firstReturned, "first barrier returns at its pass boundary") - _ = await firstTask.result + await installHandler(service, recorder: recorder) + let ran = await waitUntil { await recorder.callCount >= 1 } + await service.stopEventMonitoring() + #expect(ran, "installing a handler must re-arm pending keys") + } - let lateHeld = await waitUntil { await session.isGetContactHeld(for: keyLate) } - #expect(lateHeld, "late key is being drained in a subsequent snapshotted pass") + // MARK: - Min interval - // Late barrier joins mid-pass. Extra keys enqueued during this pass must not - // extend it (no drain-until-empty under live traffic). - let lateBarrier = BarrierFlag() - let lateTask = Task { - await service.setSyncingContacts(false) - await lateBarrier.markDone() - } + @Test + func `min interval delays second delta sync after success`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService( + session: session, + store: store, + advertSyncDebounce: .zero, + advertSyncMinInterval: .milliseconds(150) + ) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) - await session.holdNextGetContact(for: keyExtra) - await session.yieldEvent(.advertisement(publicKey: keyExtra)) - await session.releaseGetContact(for: keyLate) + let keyA = makePublicKey(seed: 0x71) + let keyB = makePublicKey(seed: 0x72) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: keyA, name: "A") + ) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: keyB, name: "B") + ) - let lateReturned = await waitUntil(timeout: .seconds(2)) { await lateBarrier.isDone } - #expect(lateReturned, "late barrier returns at the end of its snapshotted pass") - _ = await lateTask.result + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: keyA)) + let first = await waitUntil { await recorder.callCount >= 1 } + #expect(first) - // Extra is next-pass work: not saved when the late barrier returns. - let extraBeforeRelease = try await store.fetchContact(radioID: radioID, publicKey: keyExtra) - #expect(extraBeforeRelease == nil, "live enqueue mid-pass must not block the barrier") + await session.yieldEvent(.advertisement(publicKey: keyB)) + try? await Task.sleep(for: .milliseconds(40)) + #expect(await recorder.callCount == 1, "second pass must wait for min interval") - await session.releaseGetContact(for: keyExtra) - let extraSaved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: keyExtra)) != nil + let second = await waitUntil(timeout: .seconds(2)) { + await recorder.callCount >= 2 } await service.stopEventMonitoring() - #expect(extraSaved) + #expect(second) } - // MARK: - Lost-wakeup handshake + // MARK: - 0x8F drops pending key - /// Covers enqueue after an idle worker exit (starts a new worker). Production - /// keeps empty-queue observation and generation-matched ref clear in one - /// actor region with no await between them. @Test - func `enqueue after worker exit still fetches`() async throws { + func `contactDeleted removes pending key before sync runs`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x49) + // Known contact so .contactUpdated after touch proves the advert was handled + // before 0x8F (event yields are not awaited through the drain loop). + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Doomed") + ) - let keyFirst = makePublicKey(seed: 0x4C) - let keySecond = makePublicKey(seed: 0x4D) - await session.setStubbedContact(makeMeshContact(publicKey: keyFirst, name: "First"), for: keyFirst) - await session.setStubbedContact(makeMeshContact(publicKey: keySecond, name: "Second"), for: keySecond) - await session.holdNextGetContact(for: keyFirst) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + // Defer delta sync so 0x8F can clear the pending map before drain. + await service.setSyncingContacts(true) await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: keyFirst)) + await session.yieldEvent(.advertisement(publicKey: key)) + let advertHandled = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(advertHandled) - let held = await waitUntil { await session.isGetContactHeld(for: keyFirst) } - #expect(held) + await session.yieldEvent(.contactDeleted(publicKey: key)) + let deleted = await waitUntil { + await (try? store.fetchContact(radioID: radioID, publicKey: key)) == nil + } + #expect(deleted) - // Release first so worker can finish; immediately enqueue second. - await session.releaseGetContact(for: keyFirst) - // Small yield so first fetch can complete, then enqueue near exit. - try? await Task.sleep(for: .milliseconds(20)) - await session.yieldEvent(.advertisement(publicKey: keySecond)) + await service.setSyncingContacts(false) + + // Pending key was removed → setSyncingContacts(false) does not re-arm. + try? await Task.sleep(for: .milliseconds(50)) + #expect(await recorder.callCount == 0) + #expect(await counter.newContactCount == 0) - let secondSaved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: keySecond)) != nil - } await service.stopEventMonitoring() - #expect(secondSaved, "key enqueued at worker exit must not strand") + service.finishEvents() + _ = await listener.result } - // MARK: - Dedup and nil drop - @Test - func `duplicate adverts for same key perform one fetch`() async throws { + func `reconcile skips deleted contact after mid-handler 0x8F`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let key = makePublicKey(seed: 0x5E) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "Dup"), for: key) - await session.holdNextGetContact(for: key) + let key = makePublicKey(seed: 0x4A) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + return .synced + } await startMonitoring(service, session: session) await session.yieldEvent(.advertisement(publicKey: key)) - await session.yieldEvent(.advertisement(publicKey: key)) - await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) - let held = await waitUntil { await session.isGetContactHeld(for: key) } - #expect(held) - await session.releaseGetContact(for: key) + // Contact never persisted; 0x8F during handler; reconcile sees nil row. + await session.yieldEvent(.contactDeleted(publicKey: key)) + await hold.release() + + let updated = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(updated) + try? await Task.sleep(for: .milliseconds(40)) + #expect(await counter.newContactCount == 0) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil - } - let fetchCount = await session.getContactPublicKeys.filter { $0 == key }.count await service.stopEventMonitoring() - #expect(saved) - #expect(fetchCount == 1) + service.finishEvents() + _ = await listener.result } @Test - func `getContact nil drops the entry`() async throws { + func `contactDeleted during commit rolls back the resurrected contact`() async throws { let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) - let key = makePublicKey(seed: 0x6F) - // Explicit nil stub (no contact on device). - await session.setStubbedContact(nil, for: key) - - await startMonitoring(service, session: session) - await session.yieldEvent(.advertisement(publicKey: key)) - - // Wait for worker to process. - let fetched = await waitUntil { - await session.getContactPublicKeys.contains(key) + let key = makePublicKey(seed: 0x4B) + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // The batch commit re-saves a row the radio deleted while it was in flight. + _ = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + return .synced + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: key)) + // Let the 0x8F handler record the key before the commit returns. + try? await Task.sleep(for: .milliseconds(40)) + await hold.release() + + let synced = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(synced) + try? await Task.sleep(for: .milliseconds(60)) + + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact == nil, "a row re-saved after the radio deleted it must be rolled back") + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.isEmpty, "a rolled-back contact must leave no Discover row") + #expect(await counter.newContactCount == 0) + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + } + + @Test + func `rollback keeps contact the radio deleted then re-added mid-round`() async throws { + // Overwrite-oldest can free a slot then auto-add the same key again inside + // one delta round. Rollback must not treat that re-synced row as a stale + // batch resurrection: the re-advert clears the 0x8F tombstone. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let advertKey = makePublicKey(seed: 0x93) + let deletedKey = makePublicKey(seed: 0x94) + _ = try await store.saveContact( + radioID: radioID, + from: makeContactFrame(publicKey: deletedKey, name: "SlotVictim") + ) + + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // getContacts after the radio re-added the key re-creates the local row. + _ = try? await store.saveContact( + radioID: radioID, + from: makeContactFrame(publicKey: deletedKey, name: "ReAdded") + ) + return .synced + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: advertKey)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: deletedKey)) + let deleted = await waitUntil { + let gone = await (try? store.fetchContact(radioID: radioID, publicKey: deletedKey)) == nil + let tracked = await service.contactsDeletedDuringSync.contains(deletedKey) + return gone && tracked + } + #expect(deleted) + + // Re-advert: radio auto-added the contact again mid-round. + await session.yieldEvent(.advertisement(publicKey: deletedKey)) + let reAdvertPending = await waitUntil { + let pending = await service.pendingAdvertKeys.contains(deletedKey) + let cleared = await !service.contactsDeletedDuringSync.contains(deletedKey) + return pending && cleared + } + #expect(reAdvertPending, "re-advert must clear the mid-round delete tombstone") + + await hold.release() + let roundDone = await waitUntil { await service.deltaSyncTask == nil } + #expect(roundDone) + try? await Task.sleep(for: .milliseconds(40)) + + let contact = try await store.fetchContact(radioID: radioID, publicKey: deletedKey) + #expect(contact != nil, "a contact the radio re-added mid-round must survive rollback") + #expect(contact?.name == "ReAdded") + #expect( + await !service.contactsDeletedDuringSync.contains(deletedKey), + "tombstone must stay clear so reconcile does not skip the re-added key" + ) + + await service.stopEventMonitoring() + } + + @Test + func `contactDeleted during failed commit rolls back and does not re-queue`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0x4C) + let hold = HandlerHold() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // An early batch committed the row before the sync failed. + _ = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + return .failed + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: key)) + // Let the 0x8F handler record the key before the commit returns. + try? await Task.sleep(for: .milliseconds(40)) + await hold.release() + + let roundDone = await waitUntil { await service.deltaSyncTask == nil } + #expect(roundDone) + + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact == nil, "a row committed by a failed sync must still be rolled back") + #expect( + await !service.pendingAdvertKeys.contains(key), + "a radio-deleted key must not re-queue for a fetch the radio cannot answer" + ) + + await service.stopEventMonitoring() + } + + @Test + func `rollback still runs when teardown cancels the sync mid-commit`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0x4D) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Known") + ) + + let hold = HandlerHold() + let marker = CommitMarker() + await service.setDeltaSyncHandler { _ in + await hold.waitUntilReleased() + // The batch commit re-saves a row the radio deleted while it was in flight. + _ = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + await marker.markCommitted() + return .synced + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: key)) + let deleted = await waitUntil { + await (try? store.fetchContact(radioID: radioID, publicKey: key)) == nil + } + #expect(deleted) + + // Teardown cancels the round and clears the handler while the commit runs. + await service.stopEventMonitoring() + await hold.release() + + let committed = await waitUntil { await marker.committed } + #expect(committed) + + let rolledBack = await waitUntil { + await (try? store.fetchContact(radioID: radioID, publicKey: key)) == nil + } + #expect(rolledBack, "a commit landing after teardown must still be rolled back") + } + + @Test + func `contact deleted after the commit returns stays tracked for the round`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let advertKey = makePublicKey(seed: 0x4E) + let deletedKey = makePublicKey(seed: 0x4F) + await recorder.enqueuePersist(makeContactFrame(publicKey: advertKey, name: "Synced")) + await installHandler(service, recorder: recorder) + + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: deletedKey, name: "Doomed") + ) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: advertKey)) + let committed = await waitUntil { await recorder.callCount >= 1 } + #expect(committed) + + await session.yieldEvent(.contactDeleted(publicKey: deletedKey)) + let tracked = await waitUntil { + await service.contactsDeletedDuringSync.contains(deletedKey) + } + await service.stopEventMonitoring() + #expect(tracked, "a delete outside the commit must still be tracked for the round") + } + + @Test + func `reconcile skips a contact the radio deleted this round`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0x50) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Doomed") + ) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + // No handler installed, so no round drains the recorded delete before reconcile. + await startMonitoring(service, session: session) + await session.yieldEvent(.contactDeleted(publicKey: key)) + let tracked = await waitUntil { + await service.contactsDeletedDuringSync.contains(key) + } + #expect(tracked) + + // A batch commit re-saved the row the radio dropped. + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + await service.reconcile([key], insertedKeys: [key]) + + let nodes = try await store.fetchDiscoveredNodes(radioID: radioID) + #expect(nodes.isEmpty, "a deleted key must not gain a Discover row") + try? await Task.sleep(for: .milliseconds(30)) + #expect(await counter.newContactCount == 0, "a deleted key must not be announced") + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + } + + @Test + func `rolled back key does not escalate to a full refetch`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x52) + let hold = HandlerHold() + await service.setDeltaSyncHandler { fullRefetch in + let isFirst = await recorder.callCount == 0 + let result = await recorder.handle(fullRefetch: fullRefetch) + if isFirst { + await hold.waitUntilReleased() + _ = try? await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Ghost") + ) + } + return result + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let waiting = await waitUntil { await hold.isWaiting } + #expect(waiting) + + await session.yieldEvent(.contactDeleted(publicKey: key)) + let tracked = await waitUntil { + await service.contactsDeletedDuringSync.contains(key) + } + #expect(tracked) + await hold.release() + + let roundDone = await waitUntil { await service.deltaSyncTask == nil } + #expect(roundDone) + try? await Task.sleep(for: .milliseconds(80)) + await service.stopEventMonitoring() + + #expect(await recorder.fullRefetchFlags == [false], "a rolled-back key must not refetch") + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact == nil) + } + + // MARK: - notReady outcome + + @Test + func `notReady outcome drops drained keys without reschedule or budget spend`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await recorder.enqueueResult(.notReady) + // A second result would only run if notReady incorrectly re-armed. + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x57) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "NotReady") + ) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + try? await Task.sleep(for: .milliseconds(80)) + await service.stopEventMonitoring() + + #expect(await service.pendingAdvertKeys.isEmpty, "notReady must drop drained advert keys") + #expect(await recorder.callCount == 1, "notReady must not schedule another round") + #expect(await service.consecutiveDeltaSyncFailures == 0) + #expect(await service.lastDeltaSyncEnd == nil, "notReady must not stamp lastDeltaSyncEnd") + } + + // MARK: - Failure cap + + @Test + func `repeated failures stop the retry loop`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + for _ in 0..<(cap + 3) { + await recorder.enqueueResult(.failed) + } + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x53) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Flaky") + ) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let capped = await waitUntil { await recorder.callCount >= cap } + #expect(capped) + try? await Task.sleep(for: .milliseconds(120)) + await service.stopEventMonitoring() + + #expect(await recorder.callCount == cap, "retries must stop at the failure cap") + #expect(await service.pendingAdvertKeys.isEmpty, "the capped round drops its drained keys") + } + + @Test + func `busy rounds keep their keys past the failure cap`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + for _ in 0..<(cap + 2) { + await recorder.enqueueResult(.busy) + } + + // The key has no local row, so only the round that carries it can announce it. + let key = makePublicKey(seed: 0x54) + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "Late")) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let discovered = await waitUntil { await counter.newContactCount >= 1 } + #expect(discovered, "the drained key must survive collisions and reach the round that syncs it") + #expect( + await recorder.callCount > cap, + "a claim collision never reached the radio, so it must not spend the failure budget" + ) + + await service.stopEventMonitoring() + listener.cancel() + } + + // MARK: - Path-only sync + + @Test + func `pathUpdate deferred by contact sync still runs`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + await service.setSyncingContacts(true) + await startMonitoring(service, session: session) + await session.yieldEvent(.pathUpdate(publicKey: makePublicKey(seed: 0x63))) + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await recorder.callCount == 0) + + await service.setSyncingContacts(false) + let ran = await waitUntil { await recorder.callCount >= 1 } + await service.stopEventMonitoring() + #expect(ran, "a path update that fires while syncing must re-arm the delta sync") + } + + // MARK: - Contact-row newness gates the new-contact notification + + @Test + func `re-created contact with a surviving Discover row notifies again`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x74) + let frame = makeContactFrame(publicKey: key, name: "Returning") + // Deleting a contact leaves its Discover row. A re-advert for a missing + // Contact row is absent from the pre-round snapshot, so after the handler + // inserts the Contact it is in insertedKeys and notifies again. + _ = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) + await recorder.enqueuePersist(frame) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let discovered = await waitUntil { await counter.newContactCount >= 1 } + #expect(discovered, "a re-created Contact row must notify even when its Discover row survived") + try? await Task.sleep(for: .milliseconds(40)) + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + + let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + #expect(contact != nil, "the handler must have persisted the contact") + #expect(await counter.newContactCount == 1, "one insert must not notify twice") + } + + // MARK: - Store errors + + @Test + func `advert retries touch once then drops key when both fail`() async { + // Empty-round guard drops rounds with no recorded keys. A failed touch + // cannot invent known/unknown, so record nothing and retry once; the + // tradeoff is a permanently-failing store drops the advert until the next + // successful touch. + let store = MockPersistenceStore() + await store.setStubbedTouchContactHeardError(AdvertisementServiceTestError.storeUnavailable) + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: makePublicKey(seed: 0x85))) + + let retried = await waitUntil { await store.touchContactHeardCalls.count >= 2 } + #expect(retried, "touch must be retried once before giving up") + try? await Task.sleep(for: .milliseconds(80)) + await service.stopEventMonitoring() + #expect(await recorder.callCount == 0, "empty-round guard must skip when no key was recorded") + #expect(await store.touchContactHeardCalls.count == 2) + } + + @Test + func `capped failure round restores pathSyncPending`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + for _ in 0..= cap } + #expect(capped) + try? await Task.sleep(for: .milliseconds(80)) + // Cap drops drained keys but must restore pathSyncPending so a path update + // is not silently lost. The next advert or path event re-arms. + #expect(await service.pathSyncPending) + #expect(await service.pendingAdvertKeys.isEmpty) + await service.stopEventMonitoring() + } + + @Test + func `capped failure round restores escalateToFullRefetch`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + // First success with no local row escalates; then fail to the cap so the + // final drop restores escalateToFullRefetch from the drained fullRefetch flag. + await recorder.enqueueResult(.synced) + for _ in 0..= 1 + cap + } + #expect(capped) + try? await Task.sleep(for: .milliseconds(80)) + #expect( + await service.escalateToFullRefetch, + "cap must restore escalateToFullRefetch when the drained round was a full refetch" + ) + #expect(await service.pendingAdvertKeys.isEmpty) + await service.stopEventMonitoring() + } + + @Test + func `fresh advert during the capped failing round re-arms and drains`() async throws { + // An 0x80 that lands while the final failing round is awaited already spent + // its one no-op scheduleDeltaSync (the task was still set). The cap must + // detect that fresh key and re-arm, not strand it until an unrelated event. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + + let firstKey = makePublicKey(seed: 0x71) + let freshKey = makePublicKey(seed: 0x72) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: firstKey, name: "First") + ) + + let injector = CapRoundInjector(service: service, freshKey: freshKey, cap: cap) + await service.setDeltaSyncHandler { _ in await injector.handle() } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: firstKey)) + + let rearmed = await waitUntil(timeout: .seconds(3)) { await injector.calls > cap } + #expect(rearmed, "a fresh advert during the capped round must re-arm the sync") + let drained = await waitUntil { await service.pendingAdvertKeys.isEmpty } + #expect(drained, "the re-armed round drains the fresh advert key") + + await service.stopEventMonitoring() + } + + @Test + func `setSyncingContacts false re-arms owed full refetch with empty pending keys`() async throws { + // hasPendingDeltaSyncWork and the empty-round guard must agree: an owed + // escalateToFullRefetch alone is work. After connect/manual sync toggles + // isSyncingContacts, re-arm must not require pendingAdvertKeys. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + await recorder.enqueueResult(.synced) + for _ in 0..= 1 + cap + } + #expect(capped) + let escalated = await waitUntil { + let escalate = await service.escalateToFullRefetch + let emptyKeys = await service.pendingAdvertKeys.isEmpty + let idle = await service.deltaSyncTask == nil + return escalate && emptyKeys && idle } - try? await Task.sleep(for: .milliseconds(50)) + #expect(escalated) + #expect(await !service.pathSyncPending) - let contact = try await store.fetchContact(radioID: radioID, publicKey: key) + let callsBeforeRearm = await recorder.callCount + await service.setSyncingContacts(true) + await service.setSyncingContacts(false) + + let rearmed = await waitUntil(timeout: .seconds(2)) { + await recorder.callCount > callsBeforeRearm + } await service.stopEventMonitoring() - #expect(fetched) - #expect(contact == nil) + #expect(rearmed, "owed full refetch must re-arm when contact sync ends") + #expect( + await recorder.fullRefetchFlags.last == true, + "re-armed round must run as prune-free full refetch" + ) } @Test - func `keys enqueued during sync fetch after setSyncingContacts false`() async throws { + func `setDeltaSyncHandler re-arms owed full refetch with empty pending keys`() async throws { + // Same drift as setSyncingContacts: handler rewiring after a failed initial + // sync must re-arm when only escalateToFullRefetch remains. let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + let cap = AdvertisementService.maxConsecutiveDeltaSyncFailures + await recorder.enqueueResult(.synced) + for _ in 0..= 1 + cap + } + #expect(capped) + let escalated = await waitUntil { + let escalate = await service.escalateToFullRefetch + let emptyKeys = await service.pendingAdvertKeys.isEmpty + let idle = await service.deltaSyncTask == nil + return escalate && emptyKeys && idle + } + #expect(escalated) + + let callsBeforeRearm = await recorder.callCount + await service.setDeltaSyncHandler(nil) + await installHandler(service, recorder: recorder) + + let rearmed = await waitUntil(timeout: .seconds(2)) { + await recorder.callCount > callsBeforeRearm + } + await service.stopEventMonitoring() + #expect(rearmed, "reinstalling the handler must re-arm an owed full refetch") + #expect(await recorder.fullRefetchFlags.last == true) + } + + @Test + func `finishRound with stale generation leaves the new task registered`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService( + session: session, + store: store, + advertSyncDebounce: .seconds(30), + advertSyncMinInterval: .zero + ) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x89) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Gen") + ) await startMonitoring(service, session: session) await session.yieldEvent(.advertisement(publicKey: key)) - // Still no fetch while syncing. - try? await Task.sleep(for: .milliseconds(50)) - let midFetchCount = await session.getContactPublicKeys.filter { $0 == key }.count - #expect(midFetchCount == 0) + let scheduled = await waitUntil { await service.deltaSyncTask != nil } + #expect(scheduled) + let liveGeneration = await service.deltaSyncGeneration + // A stale finishRound (cancelled prior round) must not wipe the live task. + await service.finishRound(generation: liveGeneration &- 1) + #expect(await service.deltaSyncTask != nil) - await service.setSyncingContacts(false) + // Matching generation clears as designed. + await service.finishRound(generation: liveGeneration) + #expect(await service.deltaSyncTask == nil) + await service.stopEventMonitoring() + } + + @Test + func `setDeltaSyncHandler nil then reinstall still syncs pending keys`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x8A) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Reinstall") + ) + await startMonitoring(service, session: session) + await service.setDeltaSyncHandler(nil) + await session.yieldEvent(.advertisement(publicKey: key)) + try? await Task.sleep(for: .milliseconds(40)) + #expect(await recorder.callCount == 0) + #expect(await service.pendingAdvertKeys.contains(key)) + + await installHandler(service, recorder: recorder) + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran, "reinstalling a handler must re-arm for already-pending keys") + await service.stopEventMonitoring() + } + + @Test + func `snapshot fetch failure does not invent new-contact notifications`() async throws { + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x8B) + // Known local contact: a failed snapshot must not treat it as inserted. + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Known") + ) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + await startMonitoring(service, session: session) + // Fail only the snapshot read used by runDeltaSync (not touch / reconcile). + await store.setStubbedFetchContactPublicKeysError(AdvertisementServiceTestError.storeUnavailable) + await session.yieldEvent(.advertisement(publicKey: key)) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + try? await Task.sleep(for: .milliseconds(80)) + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil + #expect( + await counter.newContactCount == 0, + "snapshot failure must suppress new-contact notifications (prefer miss over false notify)" + ) + } + + @Test + func `snapshot fetch failure still adopts orphaned DMs for drained keys`() async throws { + // A failed pre-round snapshot must not set adoption keys to empty. + // The handler still inserts the Contact; orphan DMs must link after the round. + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x8C) + let prefix = Data(key.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: radioID, + contactID: nil, + channelIndex: nil, + text: "orphan before delta", + timestamp: 1_700_000_400, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + sendCount: 1, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "LateNode")) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + await startMonitoring(service, session: session) + await store.setStubbedFetchContactPublicKeysError(AdvertisementServiceTestError.storeUnavailable) + await session.yieldEvent(.advertisement(publicKey: key)) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + let linked = await waitUntil { + await store.messages[messageID]?.contactID != nil + } + await service.stopEventMonitoring() + #expect(linked, "snapshot failure must not permanently orphan DMs already received") + + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + #expect(await store.messages[messageID]?.contactID == contact.id) + #expect(contact.unreadCount == 1) + } + + @Test + func `adopting orphaned DMs emits conversationsChanged`() async throws { + // Adoption stamps lastMessageDate, which creates a conversation row. + // The mounted chat list reloads on conversationsVersion, not contactsVersion, + // so the service must emit conversationsChanged when any message is linked. + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x8E) + let prefix = Data(key.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: radioID, + contactID: nil, + channelIndex: nil, + text: "orphan needs conversation signal", + timestamp: 1_700_000_500, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + sendCount: 1, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "AdoptedDM")) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + let linked = await waitUntil { + await store.messages[messageID]?.contactID != nil + } + #expect(linked) + let signaled = await waitUntil { await counter.conversationsChangedCount >= 1 } + #expect(signaled, "adoption must emit conversationsChanged so the chat list reloads") + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + + #expect(await counter.conversationsChangedCount >= 1) + #expect(await counter.contactUpdatedCount >= 1, "contactUpdated must still fire for Discover") + } + + @Test + func `adopting orphaned DMs emits orphanDirectMessagesAdopted with the contact`() async throws { + // The adopted DM never notified at receipt (no contact then). The service + // must hand the adopting contact to the NotificationService owner so the + // banner and badge fire; a bare conversationsChanged does neither. + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x9E) + let prefix = Data(key.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: radioID, + contactID: nil, + channelIndex: nil, + text: "orphan awaiting notification", + timestamp: 1_700_000_600, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + sendCount: 1, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + await recorder.enqueuePersist(makeContactFrame(publicKey: key, name: "AdoptedDM")) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let linked = await waitUntil { await store.messages[messageID]?.contactID != nil } + #expect(linked) + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + let announced = await waitUntil { await counter.adoptedContactIDs.contains(contact.id) } + #expect(announced, "adoption must announce the contact so the banner and badge fire") + await service.stopEventMonitoring() - #expect(saved) + service.finishEvents() + _ = await listener.result } @Test - func `successful advert save emits newContactDiscovered once`() async throws { + func `delta round with no adoption does not emit conversationsChanged`() async throws { + // A successful round that links zero orphan DMs must not force a chat-list + // reload. contactUpdated still fires for Discover. let store = try await makeStore() let session = MockMeshCoreSession() let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) - let key = makePublicKey(seed: 0x81) - await session.setStubbedContact(makeMeshContact(publicKey: key, name: "Signal"), for: key) + let key = makePublicKey(seed: 0x8F) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "NoOrphans") + ) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) let counter = EventCounter() let events = service.events() let listener = Task { for await event in events { - if case .newContactDiscovered = event { - await counter.increment() - } + await counter.note(event) } } await startMonitoring(service, session: session) await session.yieldEvent(.advertisement(publicKey: key)) - let saved = await waitUntil { - await (try? store.fetchContact(radioID: radioID, publicKey: key)) != nil + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + let updated = await waitUntil { await counter.contactUpdatedCount >= 1 } + #expect(updated) + // Yield to the event loop so a wrongly unconditional emit would be observed. + try? await Task.sleep(for: .milliseconds(80)) + + await service.stopEventMonitoring() + service.finishEvents() + _ = await listener.result + + #expect( + await counter.conversationsChangedCount == 0, + "rounds that adopt nothing must not emit conversationsChanged" + ) + } + + @Test + func `materializeContactForPendingAdvert creates contact for unique pending key`() async throws { + // A DM in the debounce window needs a Contact row immediately so the live + // notify path can run. Materialize from the pending 0x80 key. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0x8D) + let mesh = makeMeshContact(publicKey: key, name: "DebounceNode") + await session.setStubbedContact(mesh, for: key) + + // No delta handler: advert records the pending key and never inserts a row. + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + let pending = await waitUntil { await service.pendingAdvertKeys.contains(key) } + #expect(pending) + #expect(try await store.fetchContact(radioID: radioID, publicKey: key) == nil) + + let prefix = Data(key.prefix(6)) + let contact = try #require( + await service.materializeContactForPendingAdvert(matchingPrefix: prefix, radioID: radioID) + ) + #expect(contact.publicKey == key) + #expect(contact.name == "DebounceNode") + #expect(await service.pendingAdvertKeys.contains(key), "key must stay pending for delta reconcile") + #expect(await session.getContactPublicKeys.contains(key)) + + // Prefix lookup is what MessagePollingService uses for the next DM hop. + let byPrefix = try await store.fetchContact(radioID: radioID, publicKeyPrefix: prefix) + #expect(byPrefix?.id == contact.id) + await service.stopEventMonitoring() + } + + @Test + func `materializeContactForPendingAdvert returns nil without pending match`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let key = makePublicKey(seed: 0x8E) + await session.setStubbedContact(makeMeshContact(publicKey: key, name: "NoPending"), for: key) + + await startMonitoring(service, session: session) + let result = await service.materializeContactForPendingAdvert( + matchingPrefix: Data(key.prefix(6)), + radioID: radioID + ) + #expect(result == nil) + #expect(await session.getContactPublicKeys.isEmpty) + await service.stopEventMonitoring() + } + + @Test + func `materializeContactForPendingAdvert returns nil on multi-match prefix`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + // Two 32-byte keys that share the same 1-byte prefix used for the query. + var keyA = makePublicKey(seed: 0x8F) + var keyB = makePublicKey(seed: 0x90) + keyA[0] = 0xAB + keyB[0] = 0xAB + #expect(keyA != keyB) + + await service.recordPendingAdvertKey(keyA) + await service.recordPendingAdvertKey(keyB) + let result = await service.materializeContactForPendingAdvert( + matchingPrefix: Data([0xAB]), + radioID: radioID + ) + #expect(result == nil) + #expect(await session.getContactPublicKeys.isEmpty) + } + + @Test + func `empty schedule with no pending work does not call the handler`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + await startMonitoring(service, session: session) + // Schedule without any pending keys/path: empty-round guard must no-op. + await service.scheduleDeltaSync() + try? await Task.sleep(for: .milliseconds(80)) + #expect(await recorder.callCount == 0) + await service.stopEventMonitoring() + } + + @Test + func `busy outcome does not stamp min-interval when backoff is non-zero`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + // Busy must not stamp lastDeltaSyncEnd; a non-zero min interval would block re-arm. + let service = makeService( + session: session, + store: store, + advertSyncDebounce: .zero, + advertSyncMinInterval: .seconds(30), + advertSyncBusyBackoff: .milliseconds(20) + ) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await recorder.enqueueResult(.busy) + await recorder.enqueueResult(.synced) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x87) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "Busy") + ) + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let second = await waitUntil(timeout: .seconds(2)) { + await recorder.callCount >= 2 } - try? await Task.sleep(for: .milliseconds(50)) + await service.stopEventMonitoring() + #expect(second, "busy must re-arm on busy backoff, not the 30s min interval") + } + + @Test + func `touch failure does not announce a known contact as new`() async throws { + // A failed touch cannot tell known from unknown, so the key is not recorded + // after the one retry. Empty-round guard then skips the handler — prefer + // losing the round over inventing a new-contact notification. + let store = MockPersistenceStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0x51) + _ = try await store.saveContact( + radioID: radioID, from: makeContactFrame(publicKey: key, name: "LongKnown") + ) + await store.setStubbedTouchContactHeardError(AdvertisementServiceTestError.storeUnavailable) + + let counter = EventCounter() + let events = service.events() + let listener = Task { + for await event in events { + await counter.note(event) + } + } + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let retried = await waitUntil { await store.touchContactHeardCalls.count >= 2 } + #expect(retried) + try? await Task.sleep(for: .milliseconds(60)) + await service.stopEventMonitoring() service.finishEvents() _ = await listener.result - let newContactCount = await counter.count - #expect(saved) - #expect(newContactCount == 1, "exactly one UI-refresh signal on successful save") + #expect(await recorder.callCount == 0, "no key recorded means empty-round guard skips") + #expect( + await counter.newContactCount == 0, + "a store error must not record a known contact as unknown" + ) + } + + @Test + func `contact lookup failure does not escalate to full refetch`() async { + let store = MockPersistenceStore() + await store.setStubbedFetchContactError(AdvertisementServiceTestError.storeUnavailable) + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: makePublicKey(seed: 0x96))) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + try? await Task.sleep(for: .milliseconds(80)) + await service.stopEventMonitoring() + + #expect(await recorder.callCount == 1, "a local read failure must not trigger a radio refetch") + #expect(await recorder.fullRefetchFlags == [false]) + } + + // MARK: - Prune safety + + @Test + func `fresh lastHeard protects contact with old lastModified from prune`() async throws { + let store = try await makeStore() + let oldStamp: UInt32 = 1_000_000 + let freshStamp = UInt32(Date().timeIntervalSince1970) + let key = makePublicKey(seed: 0x5A) + + try await store.saveContact(ContactDTO.testContact( + radioID: radioID, + publicKey: key, + name: "StaleRadio", + lastModified: oldStamp, + lastHeardTimestamp: freshStamp + )) + + // Production path: removeStaleNodes uses matchesStaleNodePrune on fetched DTOs. + let fetched = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + let days = 30 + let cutoff = UInt32(Date().addingTimeInterval(-Double(days) * 86400).timeIntervalSince1970) + + #expect(fetched.lastModified < cutoff) + #expect(!fetched.matchesStaleNodePrune(cutoff: cutoff)) + + // Control: old lastHeard and lastModified both stale → would prune. + let staleKey = makePublicKey(seed: 0x5B) + try await store.saveContact(ContactDTO.testContact( + radioID: radioID, + publicKey: staleKey, + name: "TrulyStale", + lastModified: oldStamp, + lastHeardTimestamp: oldStamp + )) + let trulyStale = try #require(await store.fetchContact(radioID: radioID, publicKey: staleKey)) + #expect(trulyStale.matchesStaleNodePrune(cutoff: cutoff)) + } + + @Test + func `unknown advert contact stamped lastHeard survives stale-node prune`() async throws { + // A 0x80 for a key with no local row cannot touch lastHeard until the delta + // insert. Contact(radioID:from:) hardcodes lastHeard = 0; without a post-insert + // stamp, a stale radio lastModified makes matchesStaleNodePrune true. + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + + let key = makePublicKey(seed: 0x5C) + let oldStamp: UInt32 = 1_000_000 + let oldDate = Date(timeIntervalSince1970: TimeInterval(oldStamp)) + await recorder.enqueuePersist( + makeContactFrame( + publicKey: key, + name: "JustHeard", + lastAdvertTimestamp: oldStamp, + lastModified: oldStamp + ) + ) + await installHandler(service, recorder: recorder) + + let beforeAdvert = Date().addingTimeInterval(-1) + await startMonitoring(service, session: session) + await session.yieldEvent(.advertisement(publicKey: key)) + + let stamped = await waitUntil { + let contact = try? await store.fetchContact(radioID: radioID, publicKey: key) + return (contact?.lastHeardTimestamp ?? 0) > 0 + } + #expect(stamped, "post-delta stamp must set lastHeardTimestamp after insert") + + let contact = try #require(await store.fetchContact(radioID: radioID, publicKey: key)) + let pruneDays = 30 + let secondsPerDay: TimeInterval = 86400 + let cutoff = UInt32( + Date().addingTimeInterval(-Double(pruneDays) * secondsPerDay).timeIntervalSince1970 + ) + + #expect(contact.lastModified == oldStamp) + #expect(contact.lastModified < cutoff) + #expect((contact.lastHeardTimestamp ?? 0) >= UInt32(beforeAdvert.timeIntervalSince1970)) + #expect(contact.recencyTimestamp >= UInt32(beforeAdvert.timeIntervalSince1970)) + #expect(!contact.matchesStaleNodePrune(cutoff: cutoff)) + + // The radio-sourced timestamps alone would still fall before the cutoff. + #expect(oldDate.timeIntervalSince1970 < Double(cutoff)) + + await service.stopEventMonitoring() + } + + @Test + func `favorite contact with stale recency does not match stale-node prune`() { + let oldStamp: UInt32 = 1_000_000 + let pruneDays = 30 + let secondsPerDay: TimeInterval = 86400 + let cutoff = UInt32( + Date().addingTimeInterval(-Double(pruneDays) * secondsPerDay).timeIntervalSince1970 + ) + let favorite = ContactDTO.testContact( + radioID: radioID, + publicKey: makePublicKey(seed: 0x5D), + name: "FavoriteStale", + lastModified: oldStamp, + lastHeardTimestamp: oldStamp, + isFavorite: true + ) + #expect(favorite.recencyTimestamp < cutoff) + #expect(!favorite.matchesStaleNodePrune(cutoff: cutoff)) } } // MARK: - Concurrency helpers -private actor BarrierFlag { - private(set) var isDone = false - func markDone() { - isDone = true +private actor CommitMarker { + private(set) var committed = false + + func markCommitted() { + committed = true } } -private actor EventCounter { - private(set) var count = 0 - func increment() { - count += 1 +private actor HandlerHold { + private var continuation: CheckedContinuation? + private(set) var isWaiting = false + /// Sticky: once released, later `waitUntilReleased` calls return immediately so a + /// follow-up delta round (re-arm / escalate) does not hang on a one-shot gate. + private var isReleased = false + + func waitUntilReleased() async { + if isReleased { return } + isWaiting = true + await withCheckedContinuation { (cont: CheckedContinuation) in + if isReleased { + cont.resume() + } else { + continuation = cont + } + } + isWaiting = false + } + + func release() { + isReleased = true + let cont = continuation + continuation = nil + cont?.resume() } } diff --git a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift index 994ee8d73..68f701a22 100644 --- a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift @@ -788,6 +788,7 @@ struct BackupIntegrationTests { latitude: existingContact.latitude, longitude: existingContact.longitude, lastModified: existingContact.lastModified, + lastHeardTimestamp: nil, nickname: "Field Ops", isBlocked: true, isMuted: true, @@ -901,6 +902,242 @@ struct BackupIntegrationTests { #expect(mergedCarol.avatarImageData == Data(repeating: 0x33, count: 8)) } + // MARK: - Test 11c: lastHeardTimestamp backup round-trip + + @Test + func `Contact lastHeardTimestamp survives encode decode export import and merge`() async throws { + let withHeard = ContactDTO.testContact(lastHeardTimestamp: 1_700_000_500) + let encoded = try JSONEncoder().encode(withHeard) + let decoded = try JSONDecoder().decode(ContactDTO.self, from: encoded) + #expect(decoded.lastHeardTimestamp == 1_700_000_500) + + var legacyJSON = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + legacyJSON.removeValue(forKey: "lastHeardTimestamp") + let legacyData = try JSONSerialization.data(withJSONObject: legacyJSON) + let legacyDecoded = try JSONDecoder().decode(ContactDTO.self, from: legacyData) + #expect(legacyDecoded.lastHeardTimestamp == nil) + + let radioID = UUID() + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let sourceContact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xF1, count: 32), + name: "Heard", + lastHeardTimestamp: 1_700_000_600 + ) + try await sourceStore.saveContact(sourceContact) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + let destStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let importResult = try await service.importBackup(envelope: envelope, into: destStore) + #expect(importResult.contactsInserted == 1) + + let inserted = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: sourceContact.publicKey) + ) + #expect(inserted.lastHeardTimestamp == 1_700_000_600) + + // Legacy envelope (missing key) materializes as model 0. + let legacyContact = ContactDTO.testContact( + radioID: radioID, + publicKey: Data(repeating: 0xF2, count: 32), + name: "Legacy", + lastHeardTimestamp: nil + ) + var legacyContactJSON = try #require( + try JSONSerialization.jsonObject(with: JSONEncoder().encode(legacyContact)) as? [String: Any] + ) + legacyContactJSON.removeValue(forKey: "lastHeardTimestamp") + let legacyContactData = try JSONSerialization.data(withJSONObject: legacyContactJSON) + let legacyContactDecoded = try JSONDecoder().decode(ContactDTO.self, from: legacyContactData) + try await destStore.saveContact(legacyContactDecoded) + let legacyRow = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: legacyContact.publicKey) + ) + #expect(legacyRow.lastHeardTimestamp == 0 || legacyRow.lastHeardTimestamp == nil) + + // Merge: local newer keeps local; backup newer adopts; future stamp clamps. + let mergeKey = Data(repeating: 0xF3, count: 32) + let localNewer = ContactDTO.testContact( + radioID: radioID, + publicKey: mergeKey, + name: "Merge", + lastHeardTimestamp: 1_700_000_900 + ) + try await destStore.saveContact(localNewer) + + let olderBackup = ContactDTO.testContact( + id: UUID(), + radioID: radioID, + publicKey: mergeKey, + name: "Merge", + lastHeardTimestamp: 1_700_000_100 + ) + _ = try await service.importBackup( + envelope: AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [olderBackup] + ), + into: destStore + ) + let afterOlder = try #require(await destStore.fetchContact(radioID: radioID, publicKey: mergeKey)) + #expect(afterOlder.lastHeardTimestamp == 1_700_000_900) + + let newerBackup = ContactDTO.testContact( + id: UUID(), + radioID: radioID, + publicKey: mergeKey, + name: "Merge", + lastHeardTimestamp: 1_700_001_000 + ) + _ = try await service.importBackup( + envelope: AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [newerBackup] + ), + into: destStore + ) + let afterNewer = try #require(await destStore.fetchContact(radioID: radioID, publicKey: mergeKey)) + #expect(afterNewer.lastHeardTimestamp == 1_700_001_000) + + let farFuture = UInt32(Date().timeIntervalSince1970) + 86400 + let futureBackup = ContactDTO.testContact( + id: UUID(), + radioID: radioID, + publicKey: mergeKey, + name: "Merge", + lastHeardTimestamp: farFuture + ) + _ = try await service.importBackup( + envelope: AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [futureBackup] + ), + into: destStore + ) + let afterFuture = try #require(await destStore.fetchContact(radioID: radioID, publicKey: mergeKey)) + let now = UInt32(Date().timeIntervalSince1970) + let tolerance = UInt32(SyncCoordinator.timestampToleranceFuture) + let upper = now > UInt32.max - tolerance ? UInt32.max : now + tolerance + #expect(afterFuture.lastHeardTimestamp ?? 0 <= upper) + #expect(afterFuture.lastHeardTimestamp ?? 0 >= 1_700_001_000) + + // Insert-only far-future stamp clamps on first import (no local row). + let insertKey = Data(repeating: 0xF4, count: 32) + let insertFuture = UInt32(Date().timeIntervalSince1970) + 86400 + let insertBackup = ContactDTO.testContact( + radioID: radioID, + publicKey: insertKey, + name: "FutureInsert", + lastHeardTimestamp: insertFuture + ) + _ = try await service.importBackup( + envelope: AppBackupEnvelope.test( + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [insertBackup] + ), + into: destStore + ) + let insertedFuture = try #require( + await destStore.fetchContact(radioID: radioID, publicKey: insertKey) + ) + let insertNow = UInt32(Date().timeIntervalSince1970) + let insertUpper = insertNow > UInt32.max - tolerance ? UInt32.max : insertNow + tolerance + #expect(insertedFuture.lastHeardTimestamp ?? 0 <= insertUpper) + #expect(insertedFuture.lastHeardTimestamp ?? 0 < insertFuture) + } + + // MARK: - Orphan DM adoption after reminted Device.id + + @Test + func `Orphan DM survives export import and adoption under reminted device id`() async throws { + let radioID = UUID() + let contactKey = Data(repeating: 0xAD, count: 32) + let prefix = Data(contactKey.prefix(6)) + let stamp = UInt32(1_700_000_700) + + let sourceStore = try await PersistenceStore.createTestDataStore(radioID: radioID) + let sourceDevice = DeviceDTO.testDevice(id: radioID, radioID: radioID) + try await sourceStore.saveDevice(sourceDevice) + + // Orphan DM stored before the contact row exists. + try await sourceStore.saveMessage( + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: nil, + text: "pre-contact dm", + timestamp: stamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(stamp)), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + let contact = ContactDTO.testContact( + radioID: radioID, publicKey: contactKey, name: "Adopted" + ) + try await sourceStore.saveContact(contact) + + let service = AppBackupService() + let exportResult = try await service.export(persistenceStore: sourceStore) + let envelope = try parseBackup(data: exportResult.data) + + // Fresh store: Device.id reminted, radioID and publicKey survive. + let destRadioID = UUID() + let destStore = try await PersistenceStore.createTestDataStore(radioID: destRadioID) + let destDevice = DeviceDTO.testDevice( + id: UUID(), + radioID: destRadioID, + publicKey: sourceDevice.publicKey + ) + try await destStore.saveDevice(destDevice) + + _ = try await service.importBackup(envelope: envelope, into: destStore) + + // After import, contact and orphan share the remapped radio partition. + let importedContacts = try await destStore.fetchContacts(radioID: destRadioID) + let imported = try #require(importedContacts.first { $0.publicKey == contactKey }) + let adopted = try await destStore.adoptOrphanedDirectMessages( + radioID: destRadioID, + contacts: [(id: imported.id, publicKey: imported.publicKey)] + ) + #expect(adopted[imported.id] == 1 || adopted.isEmpty) + // Idempotent second run. + let second = try await destStore.adoptOrphanedDirectMessages( + radioID: destRadioID, + contacts: [(id: imported.id, publicKey: imported.publicKey)] + ) + #expect(second.isEmpty) + + let messages = try await destStore.fetchMessages(contactID: imported.id, limit: 10, offset: 0) + // Either import remapped the orphan already, or adoption linked it. + let all = try await destStore.fetchAllMessages(radioID: destRadioID) + let linked = all.filter { $0.text == "pre-contact dm" } + #expect(linked.count == 1) + if let msg = linked.first { + #expect(msg.contactID == imported.id || messages.contains { $0.id == msg.id }) + } + } + // MARK: - Test 12: Merge import — channel metadata restored @Test diff --git a/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift b/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift index 0eea072da..0f88aa539 100644 --- a/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift @@ -89,6 +89,7 @@ struct BondLossPairingRecoveryTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift index 1f21dd815..ab8c24568 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectRadioIDResolutionTests.swift @@ -92,6 +92,7 @@ struct ConnectRadioIDResolutionTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift index c20074482..fff7bd2c9 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/ContactDTO+Testing.swift @@ -23,6 +23,7 @@ extension ContactDTO { latitude: Double = 0, longitude: Double = 0, lastModified: UInt32 = 0, + lastHeardTimestamp: UInt32? = nil, nickname: String? = nil, isBlocked: Bool = false, isMuted: Bool = false, @@ -45,6 +46,7 @@ extension ContactDTO { latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: lastHeardTimestamp, nickname: nickname, isBlocked: isBlocked, isMuted: isMuted, diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift index 160c4f19f..c1650a121 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift @@ -110,6 +110,12 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps private var getContactHoldContinuations: [Data: CheckedContinuation] = [:] private var getContactHoldRequested: Set = [] + /// When true, the next `getContacts` parks until `releaseGetContacts()`. + private var getContactsHoldRequested = false + private var getContactsHoldActive = false + private var getContactsHoldContinuation: CheckedContinuation? + private var getContactsStartWaiters: [CheckedContinuation] = [] + /// Error to throw from addContact public var stubbedAddContactError: Error? @@ -308,6 +314,28 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps getContactHoldContinuations[publicKey] != nil } + /// Causes the next `getContacts` to suspend until `releaseGetContacts()` is called. + public func holdNextGetContacts() { + getContactsHoldRequested = true + } + + /// Suspends until a held `getContacts` has entered its gate (claim + body in progress). + public func waitForGetContactsStart() async { + if getContactsHoldActive { return } + await withCheckedContinuation { getContactsStartWaiters.append($0) } + } + + /// Releases a held `getContacts` call. + public func releaseGetContacts() { + getContactsHoldContinuation?.resume() + getContactsHoldContinuation = nil + } + + /// Whether a `getContacts` call is currently suspended on the hold gate. + public func isGetContactsHeld() -> Bool { + getContactsHoldContinuation != nil + } + // MARK: - Protocol Methods public func sendMessage(to destination: Data, text: String, timestamp: Date, attempt: UInt8) async throws -> MessageSentInfo { @@ -329,6 +357,17 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps public func getContacts(since lastModified: Date?) async throws -> [MeshContact] { getContactsInvocations.append(lastModified) + if getContactsHoldRequested { + getContactsHoldRequested = false + getContactsHoldActive = true + while !getContactsStartWaiters.isEmpty { + getContactsStartWaiters.removeFirst().resume() + } + await withCheckedContinuation { (continuation: CheckedContinuation) in + getContactsHoldContinuation = continuation + } + getContactsHoldActive = false + } if let error = stubbedGetContactsError { throw error } diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index 4d71b5c0c..c6be5b6c7 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -21,7 +21,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { public var stubbedUpdateMessageStatusError: Error? public var stubbedSaveContactError: Error? public var stubbedFetchContactError: Error? + public var stubbedFetchContactPublicKeysError: Error? public var stubbedDeleteContactError: Error? + public var stubbedTouchContactHeardError: Error? public var stubbedSaveChannelError: Error? public var stubbedFetchChannelError: Error? public var stubbedDeleteChannelError: Error? @@ -44,6 +46,16 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { public init() {} + // MARK: - Stub Configuration + + public func setStubbedFetchContactError(_ error: Error?) { + stubbedFetchContactError = error + } + + public func setStubbedTouchContactHeardError(_ error: Error?) { + stubbedTouchContactHeardError = error + } + // MARK: - Message Operations public func isDuplicateMessage(deduplicationKey: String, radioID: UUID) async throws -> Bool { @@ -96,6 +108,12 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { return Array(filtered.dropFirst(offset).prefix(limit)) } + public func newestUnreadIncomingMessage(contactID: UUID) async throws -> MessageDTO? { + messages.values + .filter { $0.contactID == contactID && $0.direction == .incoming && !$0.isRead } + .max { $0.sortDate < $1.sortDate } + } + public func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { if let error = stubbedFetchMessageError { throw error @@ -568,6 +586,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: frame.latitude, longitude: frame.longitude, lastModified: frame.lastModified, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -580,12 +599,68 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { return (id: id, isNew: true) } - public func deleteContactIfUnreferenced(id: UUID) async throws { - // Same actor region as the message map: probe and delete with no suspension between. - if messages.values.contains(where: { $0.contactID == id }) { - return + public var touchContactHeardCalls: [(radioID: UUID, publicKey: Data, date: Date)] = [] + + @discardableResult + public func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + touchContactHeardCalls.append((radioID, publicKey, date)) + if let error = stubbedTouchContactHeardError { + throw error } - try await deleteContact(id: id) + let stamp = UInt32(date.timeIntervalSince1970) + guard let existing = contacts.values.first(where: { $0.radioID == radioID && $0.publicKey == publicKey }) else { + return false + } + let merged = max(existing.lastHeardTimestamp ?? 0, stamp) + contacts[existing.id] = ContactDTO( + 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, + lastHeardTimestamp: merged, + nickname: existing.nickname, + isBlocked: existing.isBlocked, + isMuted: existing.isMuted, + isFavorite: existing.isFavorite, + lastMessageDate: existing.lastMessageDate, + unreadCount: existing.unreadCount, + unreadMentionCount: existing.unreadMentionCount, + ocvPreset: existing.ocvPreset, + customOCVArrayString: existing.customOCVArrayString, + avatarImageData: existing.avatarImageData + ) + // Mirrors PersistenceStore: a known contact without a Discover row gets one. + let node: DiscoveredNodeDTO = if let existingNode = discoveredNodes.values.first( + where: { $0.radioID == radioID && $0.publicKey == publicKey } + ) { + existingNode + } else { + try await upsertDiscoveredNode(radioID: radioID, from: existing.toContactFrame()).node + } + discoveredNodes[node.id] = DiscoveredNodeDTO( + id: node.id, + radioID: node.radioID, + publicKey: node.publicKey, + name: node.name, + typeRawValue: node.typeRawValue, + lastHeard: date, + lastAdvertTimestamp: node.lastAdvertTimestamp, + latitude: node.latitude, + longitude: node.longitude, + outPathLength: node.outPathLength, + outPath: node.outPath, + inboundHopCount: node.inboundHopCount, + inboundHopAdvertTimestamp: node.inboundHopAdvertTimestamp + ) + return true } public func saveContact(_ dto: ContactDTO) async throws { @@ -605,6 +680,101 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { contacts.removeValue(forKey: id) } + public func deleteContactIfUnreferenced(id: UUID) async throws { + // Same actor region as the message map: probe and delete with no suspension between. + if messages.values.contains(where: { $0.contactID == id }) { + return + } + try await deleteContact(id: id) + } + + public func adoptOrphanedDirectMessages( + radioID: UUID, + contacts: [(id: UUID, publicKey: Data)] + ) async throws -> [UUID: Int] { + let candidates = contacts + guard !candidates.isEmpty else { return [:] } + var adoptedCounts: [UUID: Int] = [:] + var newestDateByContact: [UUID: Date] = [:] + var unreadByContact: [UUID: Int] = [:] + var mentionByContact: [UUID: Int] = [:] + + for (id, message) in messages { + guard message.radioID == radioID, + message.contactID == nil, + message.channelIndex == nil, + message.direction == .incoming, + let prefix = message.senderKeyPrefix, + !prefix.isEmpty, + !ReactionParser.isReactionText(message.text, isDM: true) + else { continue } + + let matches = candidates.filter { + $0.publicKey.count >= prefix.count && $0.publicKey.prefix(prefix.count) == prefix + } + guard matches.count == 1, let match = matches.first else { continue } + + var updated = message + updated.contactID = match.id + updated.deduplicationKey = DeduplicationKey.contentBased( + contactID: match.id, + channelIndex: nil, + senderNodeName: message.senderNodeName, + timestamp: message.timestamp, + content: message.text + ) + messages[id] = updated + adoptedCounts[match.id, default: 0] += 1 + let messageDate = message.sortDate + if let existing = newestDateByContact[match.id] { + newestDateByContact[match.id] = max(existing, messageDate) + } else { + newestDateByContact[match.id] = messageDate + } + if !message.isRead { + unreadByContact[match.id, default: 0] += 1 + } + if message.containsSelfMention, !message.mentionSeen { + mentionByContact[match.id, default: 0] += 1 + } + } + + for (contactID, _) in adoptedCounts { + guard let contact = self.contacts[contactID] else { continue } + let newest = newestDateByContact[contactID] + let lastMessageDate: Date? = if let newest, let existing = contact.lastMessageDate { + max(existing, newest) + } else { + newest ?? contact.lastMessageDate + } + let unreadBump = contact.isBlocked ? 0 : (unreadByContact[contactID] ?? 0) + let mentionBump = contact.isBlocked ? 0 : (mentionByContact[contactID] ?? 0) + self.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, + lastHeardTimestamp: contact.lastHeardTimestamp, + nickname: contact.nickname, + isBlocked: contact.isBlocked, + isMuted: contact.isMuted, + isFavorite: contact.isFavorite, + lastMessageDate: lastMessageDate, + unreadCount: contact.unreadCount + unreadBump, + unreadMentionCount: contact.unreadMentionCount + mentionBump + ) + } + return adoptedCounts + } + /// Mirrors the real store's cascade: messages, their pending sends, and /// reactions scoped to the contact die together, keyed by the contact ID /// value rather than the contact row. @@ -633,6 +803,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -659,6 +830,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -685,6 +857,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -749,6 +922,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -775,6 +949,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -801,6 +976,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { latitude: contact.latitude, longitude: contact.longitude, lastModified: contact.lastModified, + lastHeardTimestamp: contact.lastHeardTimestamp, nickname: contact.nickname, isBlocked: contact.isBlocked, isMuted: contact.isMuted, @@ -1678,7 +1854,14 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { } } + public func setStubbedFetchContactPublicKeysError(_ error: Error?) { + stubbedFetchContactPublicKeysError = error + } + public func fetchContactPublicKeys(radioID: UUID) async throws -> Set { + if let error = stubbedFetchContactPublicKeysError { + throw error + } if let error = stubbedFetchContactError { throw error } diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift index 4423dc736..8ad3c6452 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift @@ -1539,6 +1539,7 @@ struct PersistenceStoreTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: true, isMuted: false, @@ -2491,6 +2492,450 @@ struct PersistenceStoreTests { // MARK: - saveContact isNew / deleteContactIfUnreferenced + @Test + func `deleteContactIfUnreferenced skips when messages exist and deletes when none`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let withMessagesID = try await store.saveContact( + radioID: device.id, from: createTestContactFrame(name: "WithMsgs") + ).id + try await store.saveMessage( + MessageDTO.testDirectMessage(radioID: device.id, contactID: withMessagesID, text: "keep") + ) + try await store.deleteContactIfUnreferenced(id: withMessagesID) + #expect(try await store.fetchContact(id: withMessagesID) != nil) + #expect(try await store.fetchMessages(contactID: withMessagesID, limit: 10, offset: 0).count == 1) + + let bareID = try await store.saveContact( + radioID: device.id, from: createTestContactFrame(name: "Bare") + ).id + try await store.deleteContactIfUnreferenced(id: bareID) + #expect(try await store.fetchContact(id: bareID) == nil) + } + + // MARK: - adoptOrphanedDirectMessages + + @Test + func `adoptOrphanedDirectMessages links orphan DM and updates unread`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "LateContact") + let contactID = try await store.saveContact(radioID: device.id, from: frame).id + let prefix = Data(frame.publicKey.prefix(6)) + let stamp = UInt32(1_700_000_200) + let orphan = MessageDTO( + id: UUID(), + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "hello orphan", + timestamp: stamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(stamp)), + sortDate: Date(timeIntervalSince1970: TimeInterval(stamp)), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + sendCount: 1, + retryAttempt: 0, + maxRetryAttempts: 0, + containsSelfMention: true + ) + try await store.saveMessage(orphan) + + let adopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(adopted[contactID] == 1) + + let messages = try await store.fetchMessages(contactID: contactID, limit: 10, offset: 0) + #expect(messages.count == 1) + #expect(messages[0].contactID == contactID) + #expect(messages[0].deduplicationKey?.contains(contactID.uuidString) == true) + + let contact = try #require(await store.fetchContact(id: contactID)) + #expect(contact.unreadCount == 1) + #expect(contact.unreadMentionCount == 1) + #expect(contact.lastMessageDate != nil) + + // Idempotent: second run adopts nothing and does not double-count. + let second = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(second.isEmpty) + let after = try #require(await store.fetchContact(id: contactID)) + #expect(after.unreadCount == 1) + #expect(after.unreadMentionCount == 1) + } + + @Test + func `newestUnreadIncomingMessage returns the newest unread incoming only`() 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(name: "A") + ).id + let otherID = try await store.saveContact( + radioID: device.id, from: createTestContactFrame(name: "B") + ).id + + let base = Date(timeIntervalSince1970: 1_700_000_000) + func save( + _ text: String, contact: UUID, direction: MessageDirection, read: Bool, at offset: TimeInterval + ) async throws -> UUID { + let date = base.addingTimeInterval(offset) + let message = MessageDTO(from: Message( + radioID: device.id, + contactID: contact, + text: text, + timestamp: UInt32(date.timeIntervalSince1970), + createdAt: date, + sortDate: date, + directionRawValue: direction.rawValue, + isRead: read + )) + try await store.saveMessage(message) + return message.id + } + + _ = try await save("old unread incoming", contact: contactID, direction: .incoming, read: false, at: 10) + let newest = try await save("new unread incoming", contact: contactID, direction: .incoming, read: false, at: 30) + _ = try await save("newer but read", contact: contactID, direction: .incoming, read: true, at: 40) + _ = try await save("newer but outgoing", contact: contactID, direction: .outgoing, read: false, at: 50) + _ = try await save("other contact unread", contact: otherID, direction: .incoming, read: false, at: 60) + + let result = try await store.newestUnreadIncomingMessage(contactID: contactID) + #expect(result?.id == newest) + #expect(result?.text == "new unread incoming") + } + + @Test + func `newestUnreadIncomingMessage returns nil when no unread incoming exists`() 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()).id + + let read = MessageDTO(from: Message( + radioID: device.id, + contactID: contactID, + text: "already read", + timestamp: 1_700_000_100, + directionRawValue: MessageDirection.incoming.rawValue, + isRead: true + )) + try await store.saveMessage(read) + + #expect(try await store.newestUnreadIncomingMessage(contactID: contactID) == nil) + } + + @Test + func `adoptOrphanedDirectMessages never adopts a channel message`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + let frame = createTestContactFrame(name: "Only") + let contactID = try await store.saveContact(radioID: device.id, from: frame).id + let prefix = Data(frame.publicKey.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: 1, + text: "channel", + timestamp: 1_700_000_300, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: "Sender", + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + let adopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(adopted.isEmpty) + let all = try await store.fetchAllMessages(radioID: device.id) + let row = try #require(all.first { $0.id == messageID }) + #expect(row.contactID == nil) + #expect(row.channelIndex == 1) + } + + @Test + func `adoptOrphanedDirectMessages never adopts an outgoing message`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + let frame = createTestContactFrame(name: "Only") + let contactID = try await store.saveContact(radioID: device.id, from: frame).id + let prefix = Data(frame.publicKey.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "outgoing", + timestamp: 1_700_000_301, + createdAt: Date(), + direction: .outgoing, + status: .sent, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: true, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + let adopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(adopted.isEmpty) + let all = try await store.fetchAllMessages(radioID: device.id) + let row = try #require(all.first { $0.id == messageID }) + #expect(row.contactID == nil) + } + + @Test + func `adoptOrphanedDirectMessages never adopts reaction wire text`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + let frame = createTestContactFrame(name: "Only") + let contactID = try await store.saveContact(radioID: device.id, from: frame).id + let prefix = Data(frame.publicKey.prefix(6)) + let messageID = UUID() + // Sole contact match: only the reaction skip can leave this row unlinked. + #expect(ReactionParser.isReactionText("r:abcd:01", isDM: true)) + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "r:abcd:01", + timestamp: 1_700_000_302, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + let adopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: contactID, publicKey: frame.publicKey)] + ) + #expect(adopted.isEmpty) + let all = try await store.fetchAllMessages(radioID: device.id) + let row = try #require(all.first { $0.id == messageID }) + #expect(row.contactID == nil) + } + + @Test + func `adoptOrphanedDirectMessages skips multi-match prefix`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frameA = createTestContactFrame(name: "A") + let idA = try await store.saveContact(radioID: device.id, from: frameA).id + var keyB = frameA.publicKey + keyB[6] = keyB[6] &+ 1 + let frameB = ContactFrame( + publicKey: keyB, + type: frameA.type, + flags: frameA.flags, + outPathLength: 0, + outPath: Data(), + name: "B", + lastAdvertTimestamp: frameA.lastAdvertTimestamp, + latitude: 0, + longitude: 0, + lastModified: frameA.lastModified + ) + let idB = try await store.saveContact(radioID: device.id, from: frameB).id + let prefix = Data(frameA.publicKey.prefix(6)) + let messageID = UUID() + try await store.saveMessage( + MessageDTO( + id: messageID, + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "ambiguous", + timestamp: 1_700_000_303, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: prefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + + let multi = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: idA, publicKey: frameA.publicKey), (id: idB, publicKey: frameB.publicKey)] + ) + #expect(multi.isEmpty, "shared prefix must leave orphans unlinked") + let all = try await store.fetchAllMessages(radioID: device.id) + #expect(all.first { $0.id == messageID }?.contactID == nil) + } + + @Test + func `adoptOrphanedDirectMessages adopts blocked contact without unread bump`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frameC = createTestContactFrame(name: "Blocked") + let idC = try await store.saveContact(radioID: device.id, from: frameC).id + let blockedDTO = try #require(await store.fetchContact(id: idC)) + try await store.saveContact( + ContactDTO.testContact( + id: idC, + radioID: device.id, + publicKey: frameC.publicKey, + name: "Blocked", + typeRawValue: blockedDTO.typeRawValue, + flags: blockedDTO.flags, + outPathLength: blockedDTO.outPathLength, + outPath: blockedDTO.outPath, + lastAdvertTimestamp: blockedDTO.lastAdvertTimestamp, + lastModified: blockedDTO.lastModified, + isBlocked: true + ) + ) + let cPrefix = Data(frameC.publicKey.prefix(6)) + try await store.saveMessage( + MessageDTO( + id: UUID(), + radioID: device.id, + contactID: nil, + channelIndex: nil, + text: "blocked orphan", + timestamp: 1_700_000_304, + createdAt: Date(), + direction: .incoming, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + pathNodes: nil, + senderKeyPrefix: cPrefix, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + ) + let blockedAdopted = try await store.adoptOrphanedDirectMessages( + radioID: device.id, + contacts: [(id: idC, publicKey: frameC.publicKey)] + ) + #expect(blockedAdopted[idC] == 1) + let blocked = try #require(await store.fetchContact(id: idC)) + #expect(blocked.unreadCount == 0) + #expect(try await store.fetchMessages(contactID: idC, limit: 5, offset: 0).count == 1) + } + + @Test + func `phone clock clamp agrees between touch and backup helpers`() async throws { + let store = try await createTestStore() + let farFuture = UInt32(Date().timeIntervalSince1970) &+ (60 * 60 * 24 * 30) + let now = Date() + let fromStatic = PersistenceStore.clampedPhoneClockTimestamp(farFuture, at: now) + let fromBackup = await store.clampedBackupLastHeardTimestamp(farFuture, at: now) + #expect(fromStatic == fromBackup) + #expect(fromStatic < farFuture) + } + + @Test + func `updateDeviceLastContactSync stores radio-ahead watermark without phone clamp`() async throws { + // lastContactSync is max(contact.lastmod) from the radio RTC, not the phone clock. + // Phone-clamping it pins the watermark below every contact lastmod when the radio + // leads the phone, so firmware lastmod > since matches the whole table every delta. + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let phoneNow = UInt32(Date().timeIntervalSince1970) + let radioAhead = phoneNow &+ (30 * 24 * 60 * 60) + try await store.updateDeviceLastContactSync(radioID: device.radioID, timestamp: radioAhead) + + let fetched = try #require(await store.fetchDevice(radioID: device.radioID)) + #expect(fetched.lastContactSync == radioAhead) + } + @Test func `saveContact from frame returns isNew true then false with stable id`() async throws { let store = try await createTestStore() @@ -2522,26 +2967,58 @@ struct PersistenceStoreTests { } @Test - func `deleteContactIfUnreferenced skips when messages exist and deletes when none`() async throws { + func `touchContactHeard bumps contact and existing discovered node`() async throws { let store = try await createTestStore() let device = createTestDevice() try await store.saveDevice(device) - let withMessagesID = try await store.saveContact( - radioID: device.id, from: createTestContactFrame(name: "WithMsgs") - ).id - try await store.saveMessage( - MessageDTO.testDirectMessage(radioID: device.id, contactID: withMessagesID, text: "keep") + let frame = createTestContactFrame(name: "Heard") + _ = try await store.saveContact(radioID: device.id, from: frame) + _ = try await store.upsertDiscoveredNode(radioID: device.id, from: frame) + + let stamp = Date(timeIntervalSince1970: 1_800_000_000) + let known = try await store.touchContactHeard( + radioID: device.id, publicKey: frame.publicKey, at: stamp ) - try await store.deleteContactIfUnreferenced(id: withMessagesID) - #expect(try await store.fetchContact(id: withMessagesID) != nil) - #expect(try await store.fetchMessages(contactID: withMessagesID, limit: 10, offset: 0).count == 1) + #expect(known == true) - let bareID = try await store.saveContact( - radioID: device.id, from: createTestContactFrame(name: "Bare") - ).id - try await store.deleteContactIfUnreferenced(id: bareID) - #expect(try await store.fetchContact(id: bareID) == nil) + let contact = try #require(await store.fetchContact(radioID: device.id, publicKey: frame.publicKey)) + #expect(contact.lastHeardTimestamp == 1_800_000_000) + + let nodes = try await store.fetchDiscoveredNodes(radioID: device.id) + let node = try #require(nodes.first { $0.publicKey == frame.publicKey }) + #expect(node.lastHeard == stamp) + + let unknown = try await store.touchContactHeard( + radioID: device.id, publicKey: Data(repeating: 0xEE, count: 32), at: stamp + ) + #expect(unknown == false) + } + + @Test + func `touchContactHeard creates a missing discovered node`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let frame = createTestContactFrame(name: "NoDiscoverRow") + _ = try await store.saveContact(radioID: device.id, from: frame) + #expect(try await store.fetchDiscoveredNodes(radioID: device.id).isEmpty) + + let stamp = Date(timeIntervalSince1970: 1_800_000_500) + let known = try await store.touchContactHeard( + radioID: device.id, publicKey: frame.publicKey, at: stamp + ) + #expect(known == true) + + let nodes = try await store.fetchDiscoveredNodes(radioID: device.id) + let node = try #require(nodes.first { $0.publicKey == frame.publicKey }) + #expect(node.name == frame.name) + #expect(node.typeRawValue == frame.type.rawValue) + #expect(node.lastAdvertTimestamp == frame.lastAdvertTimestamp) + #expect(node.latitude == frame.latitude) + #expect(node.longitude == frame.longitude) + #expect(node.lastHeard == stamp) } // MARK: - Mute Tests diff --git a/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift b/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift index 303851097..e76534586 100644 --- a/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/RepeaterUnreadMigrationTests.swift @@ -31,6 +31,7 @@ struct RepeaterUnreadMigrationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift index 7c2a3af71..9bc59c308 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceAttemptCountTests.swift @@ -56,7 +56,8 @@ struct ChatSendQueueServiceAttemptCountTests { let contact = Contact( radioID: radioID, publicKey: Data(repeating: 2, count: 32), - name: "Test Contact" + name: "Test Contact", + lastHeardTimestamp: 0 ) container.mainContext.insert(contact) try container.mainContext.save() diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift index e9e443377..fb80d92c0 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatSendQueueServiceTests.swift @@ -81,7 +81,8 @@ struct ChatSendQueueServiceTests { let contact = Contact( radioID: radioID, publicKey: Data(repeating: 2, count: 32), - name: "Test Contact" + name: "Test Contact", + lastHeardTimestamp: 0 ) container.mainContext.insert(contact) try container.mainContext.save() @@ -1154,7 +1155,8 @@ struct ChatSendQueueServiceTests { let contact = Contact( radioID: radioID, publicKey: Data(repeating: 0x33, count: 32), - name: "Test Contact" + name: "Test Contact", + lastHeardTimestamp: 0 ) container.mainContext.insert(contact) try container.mainContext.save() diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift index 055b51704..511990982 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceSyncTests.swift @@ -120,4 +120,198 @@ struct ContactServiceSyncTests { let stored = try await store.fetchContacts(radioID: radioID) #expect(Set(stored.map(\.name)) == ["Alice", "Existing"]) } + + @Test + func `Manual refresh waits for an in-flight advert delta sync`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: 8, + lastContactSync: 1_704_067_200 + ) + let coordinator = SyncCoordinator() + + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) + let service = ContactService( + session: session, + dataStore: store, + syncCoordinator: coordinator, + cleanupCoordinator: nil + ) + + let gated = GatedSyncContactService() + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: radioID, + dataStore: store, + contactService: gated + ) + } + await gated.waitForSyncStart() + + let refreshTask = Task { try await service.syncContactsForRefresh(radioID: radioID) } + + try await Task.sleep(for: .milliseconds(200)) + #expect( + await session.getContactsInvocations.isEmpty, + "A manual refresh must not fetch while an advert delta sync holds the claim" + ) + + await gated.release() + #expect(await advertTask.value == .synced) + _ = try await refreshTask.value + #expect(await session.getContactsInvocations.count == 1) + } + + @Test + func `Manual refresh claim is atomic so a racing advert delta returns busy`() async throws { + // claimManualContactSync waits and sets the flag in one actor method so a racing + // performAdvertContactSync either waits behind the claim or sees manual = true. + // Separate wait and claim hops leave a gap where a delta can pass both. + // + // The refresh body is gated at getContacts so the test can observe the claim held + // before racing a second advert. Without that barrier the refresh can finish (and + // clear the flag) or not yet claim before the race is evaluated. + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: 8, + lastContactSync: 1_704_067_200 + ) + let coordinator = SyncCoordinator() + + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) + await session.holdNextGetContacts() + let service = ContactService( + session: session, + dataStore: store, + syncCoordinator: coordinator, + cleanupCoordinator: nil + ) + + let gated = GatedSyncContactService() + let firstAdvert = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: radioID, + dataStore: store, + contactService: gated + ) + } + await gated.waitForSyncStart() + + let refreshTask = Task { try await service.syncContactsForRefresh(radioID: radioID) } + // Refresh is parked in the claim wait; release the first advert so the claim lands. + await gated.release() + #expect(await firstAdvert.value == .synced) + + // Claim + set manual finished, refresh body parked in getContacts. + await session.waitForGetContactsStart() + #expect(await session.isGetContactsHeld()) + + // Claim is held for the whole refresh body. A new advert round must return .busy + // rather than entering syncContacts and racing progress events. + let secondAdvert = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: radioID, + dataStore: store, + contactService: MockContactService() + ) + #expect(secondAdvert == .busy, "Manual claim must block advert delta for the whole refresh") + + await session.releaseGetContacts() + _ = try await refreshTask.value + #expect(await session.getContactsInvocations.count == 1) + } + + @Test + func `Manual refresh surfaces syncInterrupted when the advert claim wait times out`() async throws { + // ContactService maps a timed-out claim to syncInterrupted so the refresh + // spinner stops with an error rather than hanging or racing the advert stream. + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore( + radioID: radioID, + maxChannels: 8, + lastContactSync: 1_704_067_200 + ) + let coordinator = SyncCoordinator() + await coordinator.setAdvertContactSyncWaitTimeoutOverride(.milliseconds(40)) + + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(0xAA, name: "Alice")]) + let service = ContactService( + session: session, + dataStore: store, + syncCoordinator: coordinator, + cleanupCoordinator: nil + ) + + let gated = GatedSyncContactService() + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: radioID, + dataStore: store, + contactService: gated + ) + } + await gated.waitForSyncStart() + + var surfaced: ContactServiceError? + do { + _ = try await service.syncContactsForRefresh(radioID: radioID) + } catch let error as ContactServiceError { + surfaced = error + } catch { + Issue.record("Expected ContactServiceError.syncInterrupted, got \(error)") + } + + guard case .syncInterrupted = surfaced else { + Issue.record("Refresh must map wait timeout to syncInterrupted, got \(String(describing: surfaced))") + await gated.release() + _ = await advertTask.value + return + } + #expect( + await session.getContactsInvocations.isEmpty, + "Timed-out refresh must not start a contact fetch" + ) + + await gated.release() + _ = await advertTask.value + } +} + +/// Contact service stub that parks inside `syncContacts` until released, so a test can +/// hold an advert-driven delta sync open. +private actor GatedSyncContactService: ContactServiceProtocol { + private var hasStarted = false + private var startWaiters: [CheckedContinuation] = [] + private var gate: CheckedContinuation? + private var isReleased = false + + func waitForSyncStart() async { + if hasStarted { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func release() { + isReleased = true + gate?.resume() + gate = nil + } + + func syncContacts(radioID _: UUID, since _: Date?) async throws -> ContactSyncResult { + hasStarted = true + while !startWaiters.isEmpty { + startWaiters.removeFirst().resume() + } + if !isReleased { + await withCheckedContinuation { gate = $0 } + } + return ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: true) + } } diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift index 94af370d9..371fb9d0e 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ContactServiceTests.swift @@ -438,6 +438,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -504,6 +505,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -585,6 +587,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -640,6 +643,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -689,6 +693,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -736,6 +741,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -784,6 +790,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -838,6 +845,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: "MyNickname", isBlocked: false, isMuted: false, @@ -891,6 +899,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: true, isMuted: false, @@ -977,6 +986,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: "OldNickname", isBlocked: false, isMuted: false, @@ -1018,6 +1028,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: "OldNickname", isBlocked: false, isMuted: false, @@ -1059,6 +1070,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -1100,6 +1112,7 @@ struct ContactServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: "KeepMe", isBlocked: false, isMuted: true, diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift index 09b8a35ec..039b0e639 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RegionDiscoveryServiceTests.swift @@ -20,6 +20,7 @@ struct RegionDiscoveryServiceTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift index 2c23800e9..3a97c2cfb 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift @@ -633,13 +633,14 @@ struct SyncCoordinatorTests { let invocations = await mockContactService.syncContactsInvocations #expect(invocations.count == 1) - // Verify the since parameter was passed + // The device filter is strictly greater-than, so the window is rewound one + // second to include contacts modified in the watermark second itself. let since = invocations[0].since - let expectedDate = Date(timeIntervalSince1970: Double(lastSyncTimestamp)) + let expectedDate = Date(timeIntervalSince1970: Double(lastSyncTimestamp) - 1) // 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") + #expect(actualSince == expectedDate, "Since date should include the watermark second") } // MARK: - Succeeded Parameter Tests @@ -709,7 +710,872 @@ struct SyncCoordinatorTests { #expect(succeededValues.values == [false], "Failed sync should pass succeeded: false") } - // MARK: - Resync Activity Bracket Tests + // MARK: - Advert Contact Delta Sync + + @Test + @MainActor + func `performAdvertContactSync writes watermark and uses since filter`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: watermark + ) + + let newWatermark = UInt32(Date().timeIntervalSince1970) + 60 + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 3, lastSyncTimestamp: newWatermark, isIncremental: true) + )) + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(outcome == .synced) + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + let since = try #require(invocations[0].since) + #expect( + since == Date(timeIntervalSince1970: Double(watermark) - 1), + "The device filter is strictly greater-than, so the window must include the watermark second" + ) + + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == newWatermark) + } + + @Test + @MainActor + func `performAdvertContactSync fullRefetch uses epoch zero and skips pruning`() async throws { + let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let keptKey = Data(repeating: 0x11, count: 32) + let orphanKey = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: ContactFrame( + publicKey: keptKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Kept", + lastAdvertTimestamp: 1_700_000_000, + latitude: 0, + longitude: 0, + lastModified: 1_700_000_100 + ) + ) + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: ContactFrame( + publicKey: orphanKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Orphan", + lastAdvertTimestamp: 1_700_000_000, + latitude: 0, + longitude: 0, + lastModified: 1_700_000_100 + ) + ) + + let session = MockMeshCoreSession() + // Device returns only the kept contact — orphan would be pruned on since == nil. + await session.setStubbedContacts([ + MeshContact( + id: keptKey.hexString, + publicKey: keptKey, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Kept", + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: 1_800_000_000) + ) + ]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: true, + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService + ) + #expect(outcome == .synced) + + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + #expect(sinceArgs[0] == Date(timeIntervalSince1970: 0)) + + // Prune-free: local-only orphan survives epoch-0 full refetch. + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) != nil) + + // Control: since == nil prunes the orphan. + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) == nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: keptKey) != nil) + } + + @Test + @MainActor + func `performAdvertContactSync returns busy when manual contact sync is active`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult( + contactsReceived: 1, + lastSyncTimestamp: UInt32(Date().timeIntervalSince1970), + isIncremental: true + ) + )) + + await coordinator.setManualContactSyncActive(true) + let busy = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(busy == .busy) + #expect( + await mockContactService.syncContactsInvocations.isEmpty, + "manual refresh must block advert delta from reaching the radio" + ) + + await coordinator.setManualContactSyncActive(false) + let after = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(after == .synced) + #expect(await mockContactService.syncContactsInvocations.count == 1) + } + + @Test + @MainActor + func `far-future watermark recovers with one full refetch then incremental`() async throws { + // After RTC correction, residual far-future lastmods can leave a permanent high + // watermark. Invalid stamp → prune-free epoch-0 once, then store the new max. + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let referenceNow = Date() + let phoneNow = UInt32(referenceNow.timeIntervalSince1970) + let farFuture = phoneNow &+ UInt32(30 * 24 * 60 * 60) + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: farFuture + ) + // Full recovery returns a plausible max lastmod (radio clock now pinned to phone). + let recoveredWatermark = phoneNow &+ 30 + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 4, lastSyncTimestamp: recoveredWatermark, isIncremental: true) + )) + + let first = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(first == .synced) + + let afterFirst = await mockContactService.syncContactsInvocations + #expect(afterFirst.count == 1) + #expect( + afterFirst[0].since == Date(timeIntervalSince1970: 0), + "advert invalid recovery must use prune-free epoch-0, not since=nil" + ) + var device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == recoveredWatermark) + + // Second round: recovered watermark is plausible → incremental. + let nextWatermark = recoveredWatermark &+ 10 + await mockContactService.reset() + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 1, lastSyncTimestamp: nextWatermark, isIncremental: true) + )) + + let second = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(second == .synced) + + let afterSecond = await mockContactService.syncContactsInvocations + #expect(afterSecond.count == 1) + let secondSince = try #require(afterSecond[0].since) + #expect( + secondSince == Date(timeIntervalSince1970: Double(recoveredWatermark) - 1), + "after recovery the next round must be incremental, not another full fetch" + ) + device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == nextWatermark) + } + + @Test + @MainActor + func `far-future watermark recovery retries after a failed recovery fetch`() async throws { + // A recovery full fetch that throws must not spend the one-shot latch: the + // next round must retry the prune-free epoch-0 recovery, not fall back to the + // invalid stored watermark. + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let phoneNow = UInt32(Date().timeIntervalSince1970) + let farFuture = phoneNow &+ UInt32(30 * 24 * 60 * 60) + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: farFuture + ) + + // Round 1: recovery fetch throws. + await mockContactService.setStubbedSyncContactsResult( + .failure(SyncCoordinatorError.syncFailed("boom")) + ) + let first = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(first == .failed) + let afterFirst = await mockContactService.syncContactsInvocations + #expect(afterFirst.count == 1) + #expect( + afterFirst[0].since == Date(timeIntervalSince1970: 0), + "failed round must still attempt prune-free epoch-0 recovery" + ) + + // Round 2: recovery fetch succeeds. Because round 1 failed, the latch is not + // spent, so this round must retry epoch-0 recovery — not the stored watermark. + let recoveredWatermark = phoneNow &+ 30 + await mockContactService.reset() + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 2, lastSyncTimestamp: recoveredWatermark, isIncremental: true) + )) + let second = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(second == .synced) + let afterSecond = await mockContactService.syncContactsInvocations + #expect(afterSecond.count == 1) + #expect( + afterSecond[0].since == Date(timeIntervalSince1970: 0), + "a failed recovery must not spend the one-shot; retry epoch-0, not the stored far-future watermark" + ) + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == recoveredWatermark) + } + + @Test + @MainActor + func `advert invalid watermark recovery is prune-free and keeps local-only contacts`() async throws { + // since=nil would delete local rows absent from the device response. Advert + // recovery must use epoch-0 so a background round never prunes. + let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let phoneNow = UInt32(Date().timeIntervalSince1970) + let farFuture = phoneNow &+ UInt32(30 * 24 * 60 * 60) + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: farFuture + ) + + let keptKey = Data(repeating: 0x21, count: 32) + let orphanKey = Data(repeating: 0xA9, count: 32) + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: ContactFrame( + publicKey: keptKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Kept", + lastAdvertTimestamp: phoneNow, + latitude: 0, + longitude: 0, + lastModified: phoneNow + ) + ) + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: ContactFrame( + publicKey: orphanKey, + type: .chat, + flags: 0, + outPathLength: 0, + outPath: Data(), + name: "Orphan", + lastAdvertTimestamp: phoneNow, + latitude: 0, + longitude: 0, + lastModified: phoneNow + ) + ) + + let session = MockMeshCoreSession() + await session.setStubbedContacts([ + MeshContact( + id: keptKey.hexString, + publicKey: keptKey, + type: .chat, + flags: ContactFlags(rawValue: 0), + outPathLength: 0, + outPath: Data(), + advertisedName: "Kept", + lastAdvertisement: Date(timeIntervalSince1970: TimeInterval(phoneNow)), + latitude: 0, + longitude: 0, + lastModified: Date(timeIntervalSince1970: TimeInterval(phoneNow &+ 10)) + ) + ]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService + ) + #expect(outcome == .synced) + + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + #expect( + sinceArgs[0] == Date(timeIntervalSince1970: 0), + "invalid advert recovery must full-fetch with prune-free epoch-0" + ) + #expect( + try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) != nil, + "local-only contact must survive advert invalid-watermark recovery" + ) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: keptKey) != nil) + } + + @Test + @MainActor + func `advert invalid watermark recovery full-fetches at most once per coordinator`() async throws { + // Residual far-future lastmods keep max(lastmod) implausible. Without a latch + // every advert debounce re-streams the whole table. One recovery, then stored stamp. + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let phoneNow = UInt32(Date().timeIntervalSince1970) + let farFuture = phoneNow &+ UInt32(30 * 24 * 60 * 60) + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: farFuture + ) + // Write-back stays implausible — the pathological residual-lastmod case. + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 10, lastSyncTimestamp: farFuture, isIncremental: true) + )) + + let first = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(first == .synced) + let afterFirst = await mockContactService.syncContactsInvocations + #expect(afterFirst.count == 1) + #expect(afterFirst[0].since == Date(timeIntervalSince1970: 0), "first round recovers with full fetch") + + await mockContactService.reset() + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: farFuture, isIncremental: true) + )) + + let second = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(second == .synced) + let afterSecond = await mockContactService.syncContactsInvocations + #expect(afterSecond.count == 1) + let secondSince = try #require(afterSecond[0].since) + #expect( + secondSince == Date(timeIntervalSince1970: Double(farFuture) - 1), + "second round must use the stored stamp, not another full fetch" + ) + #expect(secondSince != Date(timeIntervalSince1970: 0)) + } + + @Test + @MainActor + func `watermark a few minutes ahead of phone stays incremental`() async throws { + // Radio may lead the phone by minutes before time sync settles. That is not + // an invalid watermark and must not trigger a full recovery fetch. + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let phoneNow = UInt32(Date().timeIntervalSince1970) + let minutesLead: UInt32 = 5 * 60 + let radioLead = phoneNow &+ minutesLead + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: radioLead + ) + let newWatermark = radioLead &+ 15 + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 1, lastSyncTimestamp: newWatermark, isIncremental: true) + )) + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(outcome == .synced) + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + let since = try #require( + invocations[0].since, + "minute-scale lead must stay incremental (since non-nil), not a full recovery" + ) + #expect(since == Date(timeIntervalSince1970: Double(radioLead) - 1)) + + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == newWatermark) + } + + @Test + func `contactWatermarkUse marks far-future invalid and minute lead incremental`() { + let reference = Date(timeIntervalSince1970: 1_800_000_000) + let refSeconds = UInt32(reference.timeIntervalSince1970) + let minuteLead = refSeconds &+ (5 * 60) + let farFuture = refSeconds &+ UInt32(30 * 24 * 60 * 60) + + #expect(SyncCoordinator.contactWatermarkUse(fromLastContactSync: nil, referenceNow: reference) == .none) + #expect(SyncCoordinator.contactWatermarkUse(fromLastContactSync: 0, referenceNow: reference) == .none) + #expect( + SyncCoordinator.contactWatermarkUse(fromLastContactSync: minuteLead, referenceNow: reference) + == .incremental(minuteLead) + ) + #expect( + SyncCoordinator.contactWatermarkUse(fromLastContactSync: farFuture, referenceNow: reference) + == .invalid(stored: farFuture) + ) + } + + @MainActor + @Test(arguments: [false, true]) + func `performAdvertContactSync with zero watermark does not sync`(fullRefetch: Bool) async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 0 + ) + + let newWatermark: UInt32 = 1_800_000_000 + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 2, lastSyncTimestamp: newWatermark, isIncremental: true) + )) + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: fullRefetch, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(outcome == .notReady) + + #expect( + await mockContactService.syncContactsInvocations.isEmpty, + "No advert sync may run before the first pruning full sync succeeds" + ) + + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect( + device.lastContactSync == 0, + "Writing a watermark here would suppress the one-time pruning full sync forever" + ) + } + + @Test + @MainActor + func `Advert sync runs after a full sync that found no contacts`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 0 + ) + + // An empty radio has no contact to stamp, so the zero sentinel survives the full sync. + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: false) + )) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let device = try #require(await dataStore.fetchDevice(radioID: testDeviceID)) + #expect(device.lastContactSync == 0) + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + #expect(outcome == .synced, "The first auto-added contact must not wait for another full sync") + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 2) + #expect( + invocations[1].since == Date(timeIntervalSince1970: 0), + "With no watermark to resume from, the delta round fetches prune-free from epoch zero" + ) + } + + @Test + @MainActor + func `Full sync still prunes after an advert sync attempt with no watermark`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 0 + ) + + await mockContactService.setStubbedSyncContactsResult(.success( + ContactSyncResult(contactsReceived: 2, lastSyncTimestamp: 1_800_000_000, isIncremental: true) + )) + + _ = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + #expect( + invocations[0].since == nil, + "The first full sync after a failed connect sync must still prune" + ) + } + + @Test + @MainActor + func `performAdvertContactSync returns false while sync claimed`() async throws { + let coordinator = SyncCoordinator() + let delayingContactService = DelayingContactService() + let mockChannelService = MockChannelService() + let mockMessagePollingService = MockMessagePollingService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let startedTracker = CallTracker() + await coordinator.setSyncActivityCallbacks( + onStarted: { startedTracker.markCalled() }, + onEnded: { _ in }, + onPhaseChanged: { _ in } + ) + + let firstSyncTask = Task { + try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: delayingContactService, + channelService: mockChannelService, + messagePollingService: mockMessagePollingService + ) + } + + try await waitUntil("First sync should have started") { + startedTracker.callCount >= 1 + } + + let outcome = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: MockContactService() + ) + #expect(outcome == .busy, "A collision with another sync is not a failed exchange") + + await delayingContactService.completeSync() + firstSyncTask.cancel() + } + + @Test + @MainActor + func `Channel retry waits for an active advert contact sync instead of skipping`() async throws { + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let mockChannelService = MockChannelService() + let testDeviceID = UUID() + let retryIndices: [UInt8] = [3, 5] + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + let retryFinished = CallTracker() + let retryTask = Task { + let result = await coordinator.retryChannels( + radioID: testDeviceID, + channelService: mockChannelService, + indices: retryIndices + ) + retryFinished.markCalled() + return result + } + + try await Task.sleep(for: .milliseconds(200)) + #expect( + !retryFinished.wasCalled, + "Channel retry must wait while an advert contact sync holds the claim" + ) + #expect(await mockChannelService.retryInvocations.isEmpty) + + await gatedContactService.release() + + #expect(await advertTask.value == .synced, "Advert contact sync should complete once released") + let result = await retryTask.value + #expect(result.errors.isEmpty, "Channel retry must run for real after the advert sync releases") + #expect(await mockChannelService.retryInvocations.map(\.indices) == [retryIndices]) + } + + @Test + @MainActor + func `claimManualContactSync is atomic with the wait so advert cannot claim in the gap`() async throws { + // Separate wait and setManual hops leave a gap where a delta can pass both + // guards. One method waits then sets manual without yielding. + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + let claimTask = Task { + try await coordinator.claimManualContactSync() + } + + try await Task.sleep(for: .milliseconds(100)) + // Still waiting on the advert claim — must not have set manual yet in a way + // that would be racy; release advert so the claim can complete. + await gatedContactService.release() + #expect(await advertTask.value == .synced) + try await claimTask.value + + let racingAdvert = await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: MockContactService() + ) + #expect( + racingAdvert == .busy, + "After claimManualContactSync returns, manual flag must already be set" + ) + + await coordinator.setManualContactSyncActive(false) + } + + @Test + @MainActor + func `waitForAdvertContactSync throws when cancelled`() async throws { + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + let waitTask = Task { + try await coordinator.waitForAdvertContactSync() + } + try await Task.sleep(for: .milliseconds(50)) + waitTask.cancel() + + var threwCancellation = false + do { + try await waitTask.value + } catch is CancellationError { + threwCancellation = true + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + #expect(threwCancellation, "Cancelled wait must throw CancellationError, not hang") + + await gatedContactService.release() + _ = await advertTask.value + } + + @Test + @MainActor + func `waitForAdvertContactSync throws when the bound is reached`() async throws { + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + var timedOut = false + do { + try await coordinator.waitForAdvertContactSync(timeout: .milliseconds(40)) + } catch let error as SyncCoordinatorError { + if case let .syncFailed(message) = error { + timedOut = message == SyncCoordinator.advertContactSyncWaitTimedOutMessage + } + } + #expect(timedOut, "Wait must surface a timeout error rather than hang or silent-skip") + + await gatedContactService.release() + _ = await advertTask.value + } + + @Test + @MainActor + func `onDisconnected resumes advert claim waiters`() async throws { + let coordinator = SyncCoordinator() + let gatedContactService = GatedContactService() + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + lastContactSync: 1_704_067_200 + ) + let mockTransport = SimulatorMockTransport() + let session = MeshCoreSession(transport: mockTransport) + let services = try await ServiceContainer.forTesting(session: session) + + let advertTask = Task { + await coordinator.performAdvertContactSync( + fullRefetch: false, + radioID: testDeviceID, + dataStore: dataStore, + contactService: gatedContactService + ) + } + await gatedContactService.waitForSyncStart() + + let waitTask = Task { + try await coordinator.waitForAdvertContactSync(timeout: .seconds(5)) + } + try await Task.sleep(for: .milliseconds(50)) + + await coordinator.onDisconnected(notificationService: services.notificationService) + + // Waiter must resume when the connection drops, not sit until the advert defer. + try await waitTask.value + + await gatedContactService.release() + _ = await advertTask.value + } @Test @MainActor @@ -862,6 +1728,39 @@ actor DelayingContactService: ContactServiceProtocol { } } +/// Mock contact service that suspends inside `syncContacts` until the test releases it. +/// Lets a test hold an advert delta sync open while another sync path runs. +private actor GatedContactService: ContactServiceProtocol { + private var hasStarted = false + private var startWaiters: [CheckedContinuation] = [] + private var gate: CheckedContinuation? + private var isReleased = false + + /// Waits until `syncContacts` has entered and is holding at the gate. + func waitForSyncStart() async { + if hasStarted { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + /// Lets the held `syncContacts` call return. + func release() { + isReleased = true + gate?.resume() + gate = nil + } + + func syncContacts(radioID _: UUID, since _: Date?) async throws -> ContactSyncResult { + hasStarted = true + while !startWaiters.isEmpty { + startWaiters.removeFirst().resume() + } + if !isReleased { + await withCheckedContinuation { gate = $0 } + } + return ContactSyncResult(contactsReceived: 0, lastSyncTimestamp: 0, isIncremental: true) + } +} + /// Mock channel service that blocks in syncChannels until cancelled. actor DelayingChannelService: ChannelServiceProtocol { private var hasStarted = false diff --git a/MC1Tests/AppState/NavigationCoordinatorTests.swift b/MC1Tests/AppState/NavigationCoordinatorTests.swift index 7eac39f83..5ae2c9859 100644 --- a/MC1Tests/AppState/NavigationCoordinatorTests.swift +++ b/MC1Tests/AppState/NavigationCoordinatorTests.swift @@ -27,6 +27,7 @@ struct NavigationCoordinatorNotificationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -375,6 +376,7 @@ struct NavigationCoordinatorPendingLinkTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/AppState/NavigationStateTests.swift b/MC1Tests/AppState/NavigationStateTests.swift index 8b73bcfc5..9f7917549 100644 --- a/MC1Tests/AppState/NavigationStateTests.swift +++ b/MC1Tests/AppState/NavigationStateTests.swift @@ -25,6 +25,7 @@ struct NavigationStateTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Intents/EntityIdentityTests.swift b/MC1Tests/Intents/EntityIdentityTests.swift index b794c9925..0c635c8ef 100644 --- a/MC1Tests/Intents/EntityIdentityTests.swift +++ b/MC1Tests/Intents/EntityIdentityTests.swift @@ -34,6 +34,7 @@ struct EntityIdentityTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -215,6 +216,7 @@ struct EntityQueryScopingTests { 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, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, lastMessageDate: nil, unreadCount: 0 ) diff --git a/MC1Tests/Intents/SendMessageIntentTests.swift b/MC1Tests/Intents/SendMessageIntentTests.swift index 3532d986b..89166894a 100644 --- a/MC1Tests/Intents/SendMessageIntentTests.swift +++ b/MC1Tests/Intents/SendMessageIntentTests.swift @@ -26,6 +26,7 @@ struct SendMessageIntentTests { 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, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, isFavorite: false, lastMessageDate: nil, unreadCount: 0 ) diff --git a/MC1Tests/Models/ContactOCVTests.swift b/MC1Tests/Models/ContactOCVTests.swift index f9abc7ecd..dbe7806f5 100644 --- a/MC1Tests/Models/ContactOCVTests.swift +++ b/MC1Tests/Models/ContactOCVTests.swift @@ -19,6 +19,7 @@ struct ContactOCVTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -47,6 +48,7 @@ struct ContactOCVTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -78,6 +80,7 @@ struct ContactOCVTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -106,6 +109,7 @@ struct ContactOCVTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Models/ConversationFilteringTests.swift b/MC1Tests/Models/ConversationFilteringTests.swift index ce04271ea..d011c5f0c 100644 --- a/MC1Tests/Models/ConversationFilteringTests.swift +++ b/MC1Tests/Models/ConversationFilteringTests.swift @@ -25,6 +25,7 @@ struct ConversationFilteringTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: isMuted, diff --git a/MC1Tests/Services/InlineImagePrefetcherTests.swift b/MC1Tests/Services/InlineImagePrefetcherTests.swift index 5c934ec12..f30d051a8 100644 --- a/MC1Tests/Services/InlineImagePrefetcherTests.swift +++ b/MC1Tests/Services/InlineImagePrefetcherTests.swift @@ -328,6 +328,11 @@ private actor StubDataStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index 5a8a09eb5..4fc599a3a 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -283,6 +283,11 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} diff --git a/MC1Tests/State/ChatPrewarmRefresherTests.swift b/MC1Tests/State/ChatPrewarmRefresherTests.swift index cd5b19701..1004d14fe 100644 --- a/MC1Tests/State/ChatPrewarmRefresherTests.swift +++ b/MC1Tests/State/ChatPrewarmRefresherTests.swift @@ -129,6 +129,7 @@ struct ChatPrewarmRefresherTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/State/ChatTimelineFreshnessTests.swift b/MC1Tests/State/ChatTimelineFreshnessTests.swift index d1b0d2a90..1138040be 100644 --- a/MC1Tests/State/ChatTimelineFreshnessTests.swift +++ b/MC1Tests/State/ChatTimelineFreshnessTests.swift @@ -39,6 +39,7 @@ struct ChatTimelineFreshnessTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/State/ChatTimelinePrimerTests.swift b/MC1Tests/State/ChatTimelinePrimerTests.swift index 62a2241aa..3aa314882 100644 --- a/MC1Tests/State/ChatTimelinePrimerTests.swift +++ b/MC1Tests/State/ChatTimelinePrimerTests.swift @@ -53,6 +53,7 @@ struct ChatTimelinePrimerTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nickname, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Utilities/MentionUtilitiesTests.swift b/MC1Tests/Utilities/MentionUtilitiesTests.swift index 92c64b933..69a9abc72 100644 --- a/MC1Tests/Utilities/MentionUtilitiesTests.swift +++ b/MC1Tests/Utilities/MentionUtilitiesTests.swift @@ -221,6 +221,7 @@ struct MentionUtilitiesTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Utilities/RepeaterResolverTests.swift b/MC1Tests/Utilities/RepeaterResolverTests.swift index 9aebae653..29fc72ea9 100644 --- a/MC1Tests/Utilities/RepeaterResolverTests.swift +++ b/MC1Tests/Utilities/RepeaterResolverTests.swift @@ -27,6 +27,7 @@ struct RepeaterResolverTests { latitude: latitude, longitude: longitude, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/AppBackupViewModelTests.swift b/MC1Tests/ViewModels/AppBackupViewModelTests.swift index cedc99258..a0992da90 100644 --- a/MC1Tests/ViewModels/AppBackupViewModelTests.swift +++ b/MC1Tests/ViewModels/AppBackupViewModelTests.swift @@ -145,6 +145,7 @@ struct AppBackupViewModelTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index d79786beb..7898d3272 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -24,6 +24,7 @@ private func createTestContact( latitude: 0, longitude: 0, lastModified: UInt32(Date().timeIntervalSince1970), + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/ChatViewModelTests.swift b/MC1Tests/ViewModels/ChatViewModelTests.swift index 9abb1dbc6..3af3c11da 100644 --- a/MC1Tests/ViewModels/ChatViewModelTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelTests.swift @@ -24,6 +24,7 @@ private func createTestContact( latitude: 0, longitude: 0, lastModified: UInt32(Date().timeIntervalSince1970), + lastHeardTimestamp: 0, isBlocked: isBlocked ) return ContactDTO(from: contact) diff --git a/MC1Tests/ViewModels/ContactsViewModelTests.swift b/MC1Tests/ViewModels/ContactsViewModelTests.swift index c0e23555b..ab403dd5b 100644 --- a/MC1Tests/ViewModels/ContactsViewModelTests.swift +++ b/MC1Tests/ViewModels/ContactsViewModelTests.swift @@ -31,6 +31,7 @@ private func createContact( latitude: latitude, longitude: longitude, lastModified: lastModified, + lastHeardTimestamp: nil, nickname: nil, isBlocked: isBlocked, isMuted: false, diff --git a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift index 1464513ea..19c5eae45 100644 --- a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift +++ b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift @@ -140,6 +140,11 @@ actor MockPersistenceStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} @@ -518,6 +523,7 @@ private func createTestContact( latitude: latitude, longitude: longitude, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/MessagePathViewModelTests.swift b/MC1Tests/ViewModels/MessagePathViewModelTests.swift index 5a656c290..88b01bc16 100644 --- a/MC1Tests/ViewModels/MessagePathViewModelTests.swift +++ b/MC1Tests/ViewModels/MessagePathViewModelTests.swift @@ -25,6 +25,7 @@ struct MessagePathViewModelTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/PathManagementViewModelEditingTests.swift b/MC1Tests/ViewModels/PathManagementViewModelEditingTests.swift index a2d502130..5eae77d0e 100644 --- a/MC1Tests/ViewModels/PathManagementViewModelEditingTests.swift +++ b/MC1Tests/ViewModels/PathManagementViewModelEditingTests.swift @@ -592,6 +592,7 @@ extension ContactDTO { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift b/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift index 405a441f7..f4abc5372 100644 --- a/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift +++ b/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift @@ -64,6 +64,7 @@ struct RemoteNodeStatusHandlerSurvivalTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/RxLogViewModelTests.swift b/MC1Tests/ViewModels/RxLogViewModelTests.swift index d3ef201b8..f0a5ee46e 100644 --- a/MC1Tests/ViewModels/RxLogViewModelTests.swift +++ b/MC1Tests/ViewModels/RxLogViewModelTests.swift @@ -265,6 +265,7 @@ struct RxLogViewModelTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nickname, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift index 2b0241e82..7ec354668 100644 --- a/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift +++ b/MC1Tests/ViewModels/TelemetryHistoryOverviewViewModelTests.swift @@ -34,6 +34,7 @@ struct TelemetryHistoryOverviewViewModelTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/ViewModels/TracePathViewModelTests.swift b/MC1Tests/ViewModels/TracePathViewModelTests.swift index 5a3045fbd..4787d4d47 100644 --- a/MC1Tests/ViewModels/TracePathViewModelTests.swift +++ b/MC1Tests/ViewModels/TracePathViewModelTests.swift @@ -40,7 +40,8 @@ private func createTestContact() -> ContactDTO { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) return ContactDTO(from: contact) } @@ -880,7 +881,8 @@ struct PathCaptureTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) viewModel.addNode(ContactDTO(from: contact)) @@ -906,7 +908,8 @@ struct PathCaptureTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) viewModel.addNode(ContactDTO(from: contact)) @@ -1310,7 +1313,8 @@ struct CodeInputParsingTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) return ContactDTO(from: contact) } @@ -1506,6 +1510,7 @@ struct OutboundPathNameResolutionTests { latitude: lat, longitude: lon, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -1538,6 +1543,7 @@ struct OutboundPathNameResolutionTests { latitude: contact1.latitude, longitude: contact1.longitude, lastModified: contact1.lastModified, + lastHeardTimestamp: nil, nickname: contact1.nickname, isBlocked: contact1.isBlocked, isMuted: contact1.isMuted, @@ -1558,6 +1564,7 @@ struct OutboundPathNameResolutionTests { latitude: contact2.latitude, longitude: contact2.longitude, lastModified: contact2.lastModified, + lastHeardTimestamp: nil, nickname: contact2.nickname, isBlocked: contact2.isBlocked, isMuted: contact2.isMuted, @@ -1625,6 +1632,7 @@ struct OutboundPathNameResolutionTests { latitude: 37.0, longitude: -122.0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -1645,6 +1653,7 @@ struct OutboundPathNameResolutionTests { latitude: 38.0, longitude: -123.0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -1734,7 +1743,8 @@ struct RoomSupportTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 ) return ContactDTO(from: contact) } diff --git a/MC1Tests/Views/Chats/ChannelInfoRegionQueryTargetsTests.swift b/MC1Tests/Views/Chats/ChannelInfoRegionQueryTargetsTests.swift index 1f82f016d..8900f8752 100644 --- a/MC1Tests/Views/Chats/ChannelInfoRegionQueryTargetsTests.swift +++ b/MC1Tests/Views/Chats/ChannelInfoRegionQueryTargetsTests.swift @@ -204,6 +204,7 @@ struct ChannelInfoRegionQueryTargetsTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatConversationTypeTests.swift b/MC1Tests/Views/Chats/ChatConversationTypeTests.swift index 9e1b49135..140906c18 100644 --- a/MC1Tests/Views/Chats/ChatConversationTypeTests.swift +++ b/MC1Tests/Views/Chats/ChatConversationTypeTests.swift @@ -25,6 +25,7 @@ struct ChatConversationTypeTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nickname, isBlocked: false, isMuted: false, @@ -275,6 +276,7 @@ struct ChatConversationTypeTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatConversationViewTests.swift b/MC1Tests/Views/Chats/ChatConversationViewTests.swift index 084db8ba8..6ac4786e4 100644 --- a/MC1Tests/Views/Chats/ChatConversationViewTests.swift +++ b/MC1Tests/Views/Chats/ChatConversationViewTests.swift @@ -24,6 +24,7 @@ struct ResolveMentionTapTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: isBlocked, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift b/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift index 79a8e8c72..761294b2e 100644 --- a/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift +++ b/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift @@ -189,7 +189,8 @@ struct ChatTimelineClobberRegressionTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 )) _ = await viewModel.primeInitialMessages(for: contact) @@ -212,7 +213,8 @@ struct ChatTimelineClobberRegressionTests { lastAdvertTimestamp: 0, latitude: 0, longitude: 0, - lastModified: 0 + lastModified: 0, + lastHeardTimestamp: 0 )) _ = await viewModel.primeInitialMessages(for: otherContact) #expect(viewModel.bake.previewStates[message.id] == nil) diff --git a/MC1Tests/Views/Chats/ChatTimelineTests.swift b/MC1Tests/Views/Chats/ChatTimelineTests.swift index 16abfd326..5af286cae 100644 --- a/MC1Tests/Views/Chats/ChatTimelineTests.swift +++ b/MC1Tests/Views/Chats/ChatTimelineTests.swift @@ -30,6 +30,7 @@ struct ChatTimelineTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift b/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift index 248986091..44d462538 100644 --- a/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift @@ -485,6 +485,11 @@ private actor AdmissionStubDataStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} diff --git a/MC1Tests/Views/Chats/ChatViewModelConversationTests.swift b/MC1Tests/Views/Chats/ChatViewModelConversationTests.swift index 76abbd51e..e1eac9132 100644 --- a/MC1Tests/Views/Chats/ChatViewModelConversationTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelConversationTests.swift @@ -26,6 +26,7 @@ struct ChatViewModelConversationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatViewModelDeleteSequencingTests.swift b/MC1Tests/Views/Chats/ChatViewModelDeleteSequencingTests.swift index 8a41895a4..5d567e649 100644 --- a/MC1Tests/Views/Chats/ChatViewModelDeleteSequencingTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelDeleteSequencingTests.swift @@ -33,6 +33,7 @@ struct ChatViewModelDeleteSequencingTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift b/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift index 10b007e0b..f4d9e9b1d 100644 --- a/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelReactionIndexingTests.swift @@ -19,6 +19,7 @@ private func makeContact(radioID: UUID, name: String = "Alice") -> ContactDTO { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/ChatViewModelReloadSerializationTests.swift b/MC1Tests/Views/Chats/ChatViewModelReloadSerializationTests.swift index e643efda4..8454a3cda 100644 --- a/MC1Tests/Views/Chats/ChatViewModelReloadSerializationTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelReloadSerializationTests.swift @@ -34,6 +34,7 @@ struct ChatViewModelReloadSerializationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: isBlocked, isMuted: false, diff --git a/MC1Tests/Views/Chats/ContactMatchRowTests.swift b/MC1Tests/Views/Chats/ContactMatchRowTests.swift new file mode 100644 index 000000000..643c8839b --- /dev/null +++ b/MC1Tests/Views/Chats/ContactMatchRowTests.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("Contact recency semantics") +struct ContactMatchRowTests { + /// `recencyTimestamp` follows `lastModified`, which a path update or a + /// favorite toggle bumps with no on-air advert. Anything that must mean + /// "when did we last hear this node" reads `lastAdvertTimestamp` instead. + @Test + func `recencyTimestamp tracks lastModified not lastAdvertTimestamp`() { + let lastAdvert: UInt32 = 1000 + let lastModified: UInt32 = 10000 + let contact = ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0xAB, count: 32), + name: "Relay", + typeRawValue: ContactType.repeater.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: lastAdvert, + latitude: 0, + longitude: 0, + lastModified: lastModified, + lastHeardTimestamp: lastAdvert, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + + #expect(contact.recencyTimestamp == lastModified) + #expect(contact.recencyTimestamp != contact.lastAdvertTimestamp) + } +} diff --git a/MC1Tests/Views/Chats/ConversationListScrollPerfHarnessTests.swift b/MC1Tests/Views/Chats/ConversationListScrollPerfHarnessTests.swift index 9457d57bb..0d0b1ef27 100644 --- a/MC1Tests/Views/Chats/ConversationListScrollPerfHarnessTests.swift +++ b/MC1Tests/Views/Chats/ConversationListScrollPerfHarnessTests.swift @@ -31,6 +31,7 @@ struct ConversationListScrollPerfHarnessTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift b/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift index 4fe4f53cc..a706a21f8 100644 --- a/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift +++ b/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift @@ -19,6 +19,7 @@ struct MessageBubbleConfigurationTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nickname, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Chats/SenderContactMatcherTests.swift b/MC1Tests/Views/Chats/SenderContactMatcherTests.swift index e69fd4a06..abaf4ef13 100644 --- a/MC1Tests/Views/Chats/SenderContactMatcherTests.swift +++ b/MC1Tests/Views/Chats/SenderContactMatcherTests.swift @@ -84,6 +84,7 @@ struct SenderContactMatcherTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: isBlocked, isMuted: false, diff --git a/MC1Tests/Views/Contacts/ContactsViewModelDeleteTests.swift b/MC1Tests/Views/Contacts/ContactsViewModelDeleteTests.swift index 1c6e99b7e..d1c01a3c8 100644 --- a/MC1Tests/Views/Contacts/ContactsViewModelDeleteTests.swift +++ b/MC1Tests/Views/Contacts/ContactsViewModelDeleteTests.swift @@ -31,6 +31,7 @@ struct ContactsViewModelDeleteTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Map/MapViewModelDiscoveredTests.swift b/MC1Tests/Views/Map/MapViewModelDiscoveredTests.swift index ef332fc11..a7b0f1cdb 100644 --- a/MC1Tests/Views/Map/MapViewModelDiscoveredTests.swift +++ b/MC1Tests/Views/Map/MapViewModelDiscoveredTests.swift @@ -39,6 +39,7 @@ struct MapViewModelDiscoveredTests { latitude: latitude, longitude: longitude, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Map/MapViewModelTests.swift b/MC1Tests/Views/Map/MapViewModelTests.swift index f848f284f..291915c14 100644 --- a/MC1Tests/Views/Map/MapViewModelTests.swift +++ b/MC1Tests/Views/Map/MapViewModelTests.swift @@ -22,6 +22,7 @@ struct MapViewModelFocusTests { latitude: 37.0, longitude: -122.0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Map/TracePathMapFilterTests.swift b/MC1Tests/Views/Map/TracePathMapFilterTests.swift index 81f5bbd33..e5242e5e4 100644 --- a/MC1Tests/Views/Map/TracePathMapFilterTests.swift +++ b/MC1Tests/Views/Map/TracePathMapFilterTests.swift @@ -25,6 +25,7 @@ struct TracePathMapFilterTests { latitude: 37, longitude: -122, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -234,6 +235,7 @@ struct TracePathMapFilterTests { latitude: 0, longitude: 0, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, @@ -448,6 +450,7 @@ struct TracePathMapFilterTests { latitude: 50, longitude: -100, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift b/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift index f1df82891..d76ef4247 100644 --- a/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift +++ b/MC1Tests/Views/RemoteNodes/NeighborSNRMapBuilderTests.swift @@ -40,6 +40,7 @@ struct NeighborSNRMapBuilderTests { latitude: latitude, longitude: longitude, lastModified: 0, + lastHeardTimestamp: nil, nickname: nil, isBlocked: false, isMuted: false, diff --git a/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift b/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift index 86a5939a7..a77cf7e93 100644 --- a/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift +++ b/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift @@ -542,6 +542,11 @@ actor ParkingContactStore: PersistenceStoreProtocol { func saveContact(_ dto: ContactDTO) async throws {} func deleteContact(id: UUID) async throws {} + @discardableResult + func touchContactHeard(radioID: UUID, publicKey: Data, at date: Date) async throws -> Bool { + false + } + func updateContactLastMessage(contactID: UUID, date: Date?) async throws {} func incrementUnreadCount(contactID: UUID) async throws {} func clearUnreadCount(contactID: UUID) async throws {} From 69c42d71034e0ca11a3f6d41049137a7fa86ca66 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:39:47 -0700 Subject: [PATCH 04/47] fix(contacts): prune only on complete snapshots - Expose contactsStart total via ContactFetchResult (nil when missing) - Skip orphan prune unless the snapshot is complete and the V-contact can be identified - Force contacts-only full sync when the local table is at capacity - Add tests for capacity, truncation, shrink, missing total, and V-contact --- .../MC1Services/Services/ContactService.swift | 80 ++- .../Sync/SyncCoordinator+Sync.swift | 41 +- .../Helpers/PersistenceStore+Testing.swift | 2 + .../Mocks/MockMeshCoreSession.swift | 30 +- .../SyncCoordinatorTests.swift | 570 ++++++++++++++++++ .../Protocols/ContactSessionOps.swift | 11 + .../MeshCore/Session/ContactFetchResult.swift | 25 + .../Session/MeshCoreSession+Contacts.swift | 32 +- ...shCoreSessionCommandCorrelationTests.swift | 78 +++ 9 files changed, 835 insertions(+), 34 deletions(-) create mode 100644 MeshCore/Sources/MeshCore/Session/ContactFetchResult.swift diff --git a/MC1Services/Sources/MC1Services/Services/ContactService.swift b/MC1Services/Sources/MC1Services/Services/ContactService.swift index 1ad12bcda..e9307b935 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactService.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactService.swift @@ -133,7 +133,8 @@ public actor ContactService { /// - 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) + let fetchResult = try await session.getContactsReportingTotal(since: since) + let meshContacts = fetchResult.contacts eventBroadcaster.yield(.syncProgress(received: 0, total: meshContacts.count)) @@ -152,30 +153,8 @@ public actor ContactService { eventBroadcaster.yield(.syncProgress(received: receivedCount, total: meshContacts.count)) // On full sync, remove local contacts that no longer exist on device. - // Never prune the ZephCore V-contact: it is omitted from GET_CONTACTS while - // clock-deferred or disabled, but is not a real-table orphan. if since == nil { - let localContacts = try await dataStore.fetchContacts(radioID: radioID) - let selfPublicKey = try? await dataStore.fetchDevice(radioID: radioID)?.publicKey - let orphans = localContacts.filter { contact in - guard !devicePublicKeys.contains(contact.publicKey) else { return false } - if let selfPublicKey, - VContactIdentity.isVContact(publicKey: contact.publicKey, selfPublicKey: selfPublicKey) { - return false - } - return true - } - 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.deleteContact(id: localContact.id) - await cleanupCoordinator?.handleCleanup( - contactID: localContact.id, reason: .deleted, publicKey: localContact.publicKey - ) - } + try await pruneOrphans(radioID: radioID, devicePublicKeys: devicePublicKeys, reportedTotal: fetchResult.reportedTotal) } return ContactSyncResult( @@ -188,6 +167,59 @@ public actor ContactService { } } + /// Removes local contacts that a full fetch proves are gone from the device. + /// + /// Prunes only on a complete snapshot: received count must meet the + /// `contactsStart` total. A truncated stream leaves keys missing without a + /// real device deletion, so those must not prune. A genuine shrink lowers the + /// reported total in step, so a complete-but-smaller reply still prunes. + /// Both counts include the ZephCore V-contact when firmware streams it. + /// + /// A `nil` `reportedTotal` means the reply carried no `contactsStart` header, + /// so the device total is unknown and the snapshot cannot be proven complete; + /// the prune skips. + /// + /// Without a usable self public key the V-contact cannot be identified, so the + /// prune skips rather than risk deleting a local V-contact row that firmware + /// omitted while clock-deferred. + private func pruneOrphans(radioID: UUID, devicePublicKeys: Set, reportedTotal: Int?) async throws { + guard let reportedTotal else { + logger.notice("Full sync prune skipped: device sent no contact total (missing contactsStart header)") + return + } + guard devicePublicKeys.count >= reportedTotal else { + logger.notice( + "Full sync prune skipped: received \(devicePublicKeys.count) of \(reportedTotal) reported device contacts (incomplete snapshot)" + ) + return + } + guard let selfPublicKey = try? await dataStore.fetchDevice(radioID: radioID)?.publicKey, + selfPublicKey.count == ProtocolLimits.publicKeySize else { + logger.notice("Full sync prune skipped: self public key unavailable or invalid, cannot exclude the V-contact") + return + } + + let localContacts = try await dataStore.fetchContacts(radioID: radioID) + let orphans = localContacts.filter { contact in + guard !devicePublicKeys.contains(contact.publicKey) else { return false } + if VContactIdentity.isVContact(publicKey: contact.publicKey, selfPublicKey: selfPublicKey) { + return false + } + return true + } + 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.deleteContact(id: localContact.id) + await cleanupCoordinator?.handleCleanup( + contactID: localContact.id, reason: .deleted, publicKey: localContact.publicKey + ) + } + } + /// Full contact sync for a user-initiated refresh. /// /// Atomically waits out an advert-driven delta sync and claims the manual diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift index 1558a8121..a46d26565 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+Sync.swift @@ -531,14 +531,45 @@ extension SyncCoordinator { // 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) + // At capacity, force a pruning contact fetch (`since == nil`). Offline + // eviction is invisible to the incremental watermark. Keep this contacts- + // only: do not set `forceFullSync`, which also forces channel sync. + // Decide here, before the watermark switch, so the one-shot invalid- + // watermark recovery token is not spent. Exclude the virtual V-contact + // from the count. Skip when maxContacts is unknown or zero; a count-read + // failure leaves atCapacity false. + var atCapacity = false + var realContactCount = 0 + if let maxContacts = device?.maxContacts, maxContacts > 0 { + do { + let keys = try await dataStore.fetchContactPublicKeys(radioID: radioID) + realContactCount = keys.count + if let selfPublicKey = device?.publicKey, + let vContactKey = VContactIdentity.publicKey(forSelfPublicKey: selfPublicKey), + keys.contains(vContactKey) { + realContactCount -= 1 + } + atCapacity = realContactCount >= Int(maxContacts) + } catch { + logger.error("Failed to read local contact count for capacity check: \(error)") + } + } + + // Phase 1: Contacts (incremental unless forced full or at capacity) var ranInvalidWatermarkRecovery = false let lastContactSync: Date? - if forceFullSync { + if forceFullSync || atCapacity { lastContactSync = nil - logger.notice( - "[Sync] Phase start: contacts (FULL sync, reason=forceFullSync) — local contacts not on device will be pruned" - ) + if forceFullSync { + logger.notice( + "[Sync] Phase start: contacts (FULL sync, reason=forceFullSync) — local contacts not on device will be pruned" + ) + } else { + let maxContacts = device?.maxContacts ?? 0 + logger.notice( + "[Sync] Phase start: contacts (FULL sync, reason=at capacity (local \(realContactCount) of max \(maxContacts))) — local contacts not on device will be pruned" + ) + } } else { switch Self.contactWatermarkUse(fromLastContactSync: device?.lastContactSync) { case .none: diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift index 9d807a649..2ea8fd27c 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/PersistenceStore+Testing.swift @@ -6,6 +6,7 @@ extension PersistenceStore { static func createTestDataStore( radioID: UUID, maxChannels: UInt8 = 8, + maxContacts: UInt16 = 100, lastContactSync: UInt32 = 0 ) async throws -> PersistenceStore { let container = try PersistenceStore.createContainer(inMemory: true) @@ -15,6 +16,7 @@ extension PersistenceStore { radioID: radioID, firmwareVersion: 8, firmwareVersionString: "v1.0.0", + maxContacts: maxContacts, maxChannels: maxChannels, multiAcks: 0, lastContactSync: lastContactSync diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift index c1650a121..6f4034582 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockMeshCoreSession.swift @@ -91,6 +91,15 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps /// Contacts to return from getContacts public var stubbedContacts: [MeshContact] = [] + /// Reported total for `getContactsReportingTotal`. `nil` returns + /// `stubbedContacts.count` (a complete stream). Set it above the stubbed + /// count to simulate a truncated stream. + public var stubbedReportedTotal: Int? + + /// When `true`, `getContactsReportingTotal` returns a `nil` total, simulating a + /// reply that carried no `contactsStart` header. + public var stubbedReportsNoTotal = false + /// Error to throw from getContacts public var stubbedGetContactsError: Error? @@ -245,6 +254,18 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps stubbedContacts = contacts } + /// Sets the reported total for `getContactsReportingTotal` (isolated setter). + /// Set it above the stubbed contact count to simulate a truncated stream. + public func setStubbedReportedTotal(_ total: Int?) { + stubbedReportedTotal = total + } + + /// Makes `getContactsReportingTotal` return a `nil` total (no `contactsStart` + /// header), through an isolated setter. + public func setStubbedReportsNoTotal(_ reportsNoTotal: Bool) { + stubbedReportsNoTotal = reportsNoTotal + } + /// Sets the self info returned by `currentSelfInfo`, through an isolated setter /// for the same actor-isolation reason as `setStubbedContacts`. public func setCurrentSelfInfo(_ selfInfo: SelfInfo?) { @@ -356,6 +377,10 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps } public func getContacts(since lastModified: Date?) async throws -> [MeshContact] { + try await getContactsReportingTotal(since: lastModified).contacts + } + + public func getContactsReportingTotal(since lastModified: Date?) async throws -> ContactFetchResult { getContactsInvocations.append(lastModified) if getContactsHoldRequested { getContactsHoldRequested = false @@ -371,7 +396,10 @@ public actor MockMeshCoreSession: MeshCoreSessionProtocol, AdvertisingSessionOps if let error = stubbedGetContactsError { throw error } - return stubbedContacts + return ContactFetchResult( + contacts: stubbedContacts, + reportedTotal: stubbedReportsNoTotal ? nil : (stubbedReportedTotal ?? stubbedContacts.count) + ) } public func getContact(publicKey: Data) async throws -> MeshContact? { diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift index 3a97c2cfb..a4f18ba70 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorTests.swift @@ -10,15 +10,58 @@ struct SyncCoordinatorTests { private func createTestDataStore( radioID: UUID, maxChannels: UInt8 = 8, + maxContacts: UInt16 = 100, lastContactSync: UInt32 = 0 ) async throws -> PersistenceStore { try await PersistenceStore.createTestDataStore( radioID: radioID, maxChannels: maxChannels, + maxContacts: maxContacts, lastContactSync: lastContactSync ) } + private func contactFrame( + key: Data, + name: String, + flags: UInt8 = 0, + lastModified: UInt32 = 1_700_000_100 + ) -> ContactFrame { + ContactFrame( + publicKey: key, + type: .chat, + flags: flags, + outPathLength: 0, + outPath: Data(), + name: name, + lastAdvertTimestamp: 1_700_000_000, + latitude: 0, + longitude: 0, + lastModified: lastModified + ) + } + + private func meshContact( + key: Data, + name: String, + flags: ContactFlags = ContactFlags(rawValue: 0), + lastModified: Date = Date(timeIntervalSince1970: 1_800_000_000) + ) -> MeshContact { + MeshContact( + id: key.hexString, + publicKey: key, + type: .chat, + flags: flags, + outPathLength: 0, + outPath: Data(), + advertisedName: name, + lastAdvertisement: Date(timeIntervalSince1970: 1_700_000_000), + latitude: 0, + longitude: 0, + lastModified: lastModified + ) + } + @Test func `SyncState cases are distinct`() { let idle = SyncState.idle @@ -1631,6 +1674,533 @@ struct SyncCoordinatorTests { // is already true and endSyncActivityOnce should be a no-op. #expect(succeededValues.values.isEmpty, "onDisconnected should not end the resync bracket") } + + // MARK: - Capacity-capped contact staleness at connect + + @Test + @MainActor + func `At-capacity connect forces full contact sync and prunes the missing non-favourite`() async throws { + // Valid watermark would normally drive incremental sync, which never prunes. + // localCount == maxContacts forces a full (pruning) fetch instead. + let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let maxContacts: UInt16 = 3 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: maxContacts, + lastContactSync: watermark + ) + + let keptA = Data(repeating: 0x11, count: 32) + let keptB = Data(repeating: 0x22, count: 32) + let evicted = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: keptA, name: "KeptA")) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: keptB, name: "KeptB")) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: evicted, name: "Evicted")) + + let session = MockMeshCoreSession() + // Radio table is full and missing the evicted key (disconnected eviction). + await session.setStubbedContacts([ + meshContact(key: keptA, name: "KeptA"), + meshContact(key: keptB, name: "KeptB"), + meshContact(key: Data(repeating: 0x33, count: 32), name: "Newcomer") + ]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + #expect(sinceArgs[0] == nil, "At-capacity connect must force since == nil (full prune path)") + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: evicted) == nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: keptA) != nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: keptB) != nil) + } + + @Test + @MainActor + func `Below-capacity connect keeps incremental contact sync and does not prune`() async throws { + let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 100, + lastContactSync: watermark + ) + + let kept = Data(repeating: 0x11, count: 32) + let orphan = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: kept, name: "Kept")) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: orphan, name: "Orphan")) + + let session = MockMeshCoreSession() + // Incremental batch omits the orphan; below capacity must not prune it. + await session.setStubbedContacts([ + meshContact(key: kept, name: "Kept") + ]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + let since = try #require(sinceArgs[0], "Below-capacity connect must pass a non-nil since") + #expect(since == Date(timeIntervalSince1970: Double(watermark) - 1)) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphan) != nil) + } + + @Test + @MainActor + func `maxContacts zero does not force capacity full contact sync`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 0, + lastContactSync: watermark + ) + // Even with local contacts present, maxContacts == 0 must not force full. + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: Data(repeating: 0x11, count: 32), name: "Any") + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: mockContactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + #expect(invocations[0].since != nil, "maxContacts == 0 must not force since == nil") + } + + @Test + @MainActor + func `Contact count read failure falls back to incremental and keeps connection usable`() async throws { + let coordinator = SyncCoordinator() + let mockContactService = MockContactService() + let testDeviceID = UUID() + let watermark: UInt32 = 1_704_067_200 + let store = MockPersistenceStore() + try await store.saveDevice( + DeviceDTO.testDevice( + id: testDeviceID, + radioID: testDeviceID, + maxContacts: 3, + lastContactSync: watermark + ) + ) + // Count would be at capacity if the read succeeded; force the read to fail. + for byte: UInt8 in [0x11, 0x22, 0x33] { + try await store.saveContact( + ContactDTO( + id: UUID(), + radioID: testDeviceID, + publicKey: Data(repeating: byte, count: 32), + name: "C\(byte)", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + ) + } + await store.setStubbedFetchContactPublicKeysError( + NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "count read failed"]) + ) + + let result = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: store, + contactService: mockContactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + #expect(result.isConnectionUsable) + let invocations = await mockContactService.syncContactsInvocations + #expect(invocations.count == 1) + #expect( + invocations[0].since != nil, + "Count-read failure must degrade to incremental (today's behavior)" + ) + } + + @Test + @MainActor + func `Full sync prune skips a truncated stream the ratio floor would have pruned`() async throws { + // Device reports 4 contacts but the stream ends after 3 (favourite dropped). + // received < reportedTotal, so the prune skips and every local row survives. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let favouriteKey = Data(repeating: 0xAA, count: 32) + let otherKeys = [ + Data(repeating: 0x11, count: 32), + Data(repeating: 0x22, count: 32), + Data(repeating: 0x33, count: 32) + ] + let (favouriteID, _) = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: favouriteKey, name: "Favourite", flags: ContactFlags.favorite.rawValue) + ) + // ContactFrame maps flags bit 0 to isFavorite; keep a message under the favourite. + let favourite = try #require(await dataStore.fetchContact(id: favouriteID)) + #expect(favourite.isFavorite) + try await dataStore.saveMessage(MessageDTO(from: Message( + radioID: testDeviceID, + contactID: favouriteID, + text: "keep me", + timestamp: 1_700_000_000 + ))) + for (index, key) in otherKeys.enumerated() { + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: key, name: "Other\(index)") + ) + } + + let session = MockMeshCoreSession() + // Truncated: 3 of the 4 the device reports (the favourite frame was dropped). + await session.setStubbedContacts(otherKeys.enumerated().map { index, key in + meshContact(key: key, name: "Other\(index)") + }) + await session.setStubbedReportedTotal(4) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: favouriteKey) != nil) + let messages = try await dataStore.fetchMessages(contactID: favouriteID, limit: 10, offset: 0) + #expect(messages.count == 1) + #expect(messages.first?.text == "keep me") + for key in otherKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } + + @Test + @MainActor + func `Full sync prune removes a favourite absent from a complete device set`() async throws { + // Device returns a complete list that omits one favourite the user deleted on + // the radio. received (3) == reported (3), so the snapshot is complete and the + // prune runs; the favourite has no favourite-only exemption. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let favouriteKey = Data(repeating: 0xAA, count: 32) + let keptKeys = [ + Data(repeating: 0x11, count: 32), + Data(repeating: 0x22, count: 32), + Data(repeating: 0x33, count: 32) + ] + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: favouriteKey, name: "Favourite", flags: ContactFlags.favorite.rawValue) + ) + for (index, key) in keptKeys.enumerated() { + _ = try await dataStore.saveContact( + radioID: testDeviceID, + from: contactFrame(key: key, name: "Kept\(index)") + ) + } + + let session = MockMeshCoreSession() + await session.setStubbedContacts(keptKeys.enumerated().map { index, key in + meshContact(key: key, name: "Kept\(index)") + }) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: favouriteKey) == nil) + for key in keptKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } + + @Test + @MainActor + func `Full sync prune removes orphans when the device genuinely shrank below half`() async throws { + // Complete device reply with one contact: received == reportedTotal, so the + // three stale local rows are pruned. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let survivorKey = Data(repeating: 0x11, count: 32) + let staleKeys = [ + Data(repeating: 0xAA, count: 32), + Data(repeating: 0x22, count: 32), + Data(repeating: 0x33, count: 32) + ] + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: survivorKey, name: "Survivor")) + for (index, key) in staleKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Stale\(index)")) + } + + let session = MockMeshCoreSession() + await session.setStubbedContacts([meshContact(key: survivorKey, name: "Survivor")]) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: survivorKey) != nil) + for key in staleKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) == nil) + } + } + + @Test + @MainActor + func `Full sync prune preserves the local V-contact omitted from a complete stream`() async throws { + // ZephCore omits the V-contact while clock-deferred, so a complete stream can + // exclude it. It must survive the prune; a genuine orphan alongside it must not. + let testDeviceID = UUID() + let selfPublicKey = Data(repeating: 0x01, count: 32) // DeviceDTO.testDevice default + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let vContactKey = try #require(VContactIdentity.publicKey(forSelfPublicKey: selfPublicKey)) + let realKeys = [Data(repeating: 0x11, count: 32), Data(repeating: 0x22, count: 32)] + let orphanKey = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: vContactKey, name: "V-Contact")) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: orphanKey, name: "Orphan")) + for (index, key) in realKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Real\(index)")) + } + + let session = MockMeshCoreSession() + // Complete stream of the two real contacts; V-contact and orphan both absent. + await session.setStubbedContacts(realKeys.enumerated().map { index, key in + meshContact(key: key, name: "Real\(index)") + }) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: vContactKey) != nil) + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) == nil) + for key in realKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } + + @Test + @MainActor + func `V-contact in the last slot keeps the connect incremental, not at capacity`() async throws { + // maxContacts - 1 real contacts plus the local V-contact fill every slot, but + // the V-contact is virtual. Excluding it keeps the radio below capacity, so a + // valid watermark still drives an incremental (non-pruning) sync. + let coordinator = SyncCoordinator() + let testDeviceID = UUID() + let selfPublicKey = Data(repeating: 0x01, count: 32) // DeviceDTO.testDevice default + let watermark: UInt32 = 1_704_067_200 + let maxContacts: UInt16 = 3 + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: maxContacts, + lastContactSync: watermark + ) + + let vContactKey = try #require(VContactIdentity.publicKey(forSelfPublicKey: selfPublicKey)) + let realKeys = [Data(repeating: 0x11, count: 32), Data(repeating: 0x22, count: 32)] + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: vContactKey, name: "V-Contact")) + for (index, key) in realKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Real\(index)")) + } + + let session = MockMeshCoreSession() + await session.setStubbedContacts(realKeys.enumerated().map { index, key in + meshContact(key: key, name: "Real\(index)") + }) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await coordinator.performFullSync( + radioID: testDeviceID, + dataStore: dataStore, + contactService: contactService, + channelService: MockChannelService(), + messagePollingService: MockMessagePollingService() + ) + + let sinceArgs = await session.getContactsInvocations + #expect(sinceArgs.count == 1) + #expect(sinceArgs[0] != nil, "V-contact in the last slot must not trip at-capacity; sync stays incremental") + } + + @Test + @MainActor + func `Full sync prune skips when the device sent no contact total`() async throws { + // A reply with no contactsStart header leaves the total unknown, so the + // snapshot cannot be proven complete. The prune must skip even though the + // received set would otherwise look like the whole table. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + + let realKeys = [Data(repeating: 0x11, count: 32), Data(repeating: 0x22, count: 32)] + let orphanKey = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: orphanKey, name: "Orphan")) + for (index, key) in realKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Real\(index)")) + } + + let session = MockMeshCoreSession() + await session.setStubbedContacts(realKeys.enumerated().map { index, key in + meshContact(key: key, name: "Real\(index)") + }) + await session.setStubbedReportsNoTotal(true) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect( + try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) != nil, + "Prune must skip when the device reports no total, so the orphan survives" + ) + for key in realKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } + + @Test + @MainActor + func `Full sync prune skips when the self public key is the wrong length`() async throws { + // A device row with a malformed (non-32-byte) self key cannot identify the + // V-contact, so the prune must skip rather than risk deleting it. A local + // orphan survives even though the device set is complete. + let testDeviceID = UUID() + let dataStore = try await createTestDataStore( + radioID: testDeviceID, + maxContacts: 4, + lastContactSync: 1_704_067_200 + ) + try await dataStore.saveDevice(DeviceDTO.testDevice( + id: testDeviceID, + radioID: testDeviceID, + publicKey: Data(repeating: 0x01, count: 8), + maxContacts: 4, + lastContactSync: 1_704_067_200 + )) + + let realKeys = [Data(repeating: 0x11, count: 32), Data(repeating: 0x22, count: 32)] + let orphanKey = Data(repeating: 0x99, count: 32) + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: orphanKey, name: "Orphan")) + for (index, key) in realKeys.enumerated() { + _ = try await dataStore.saveContact(radioID: testDeviceID, from: contactFrame(key: key, name: "Real\(index)")) + } + + let session = MockMeshCoreSession() + // Complete stream of the two real contacts; received == reportedTotal. + await session.setStubbedContacts(realKeys.enumerated().map { index, key in + meshContact(key: key, name: "Real\(index)") + }) + let contactService = ContactService( + session: session, + dataStore: dataStore, + syncCoordinator: nil, + cleanupCoordinator: nil + ) + + _ = try await contactService.syncContacts(radioID: testDeviceID, since: nil) + + #expect( + try await dataStore.fetchContact(radioID: testDeviceID, publicKey: orphanKey) != nil, + "Prune must skip when the self key is malformed, so the orphan survives" + ) + for key in realKeys { + #expect(try await dataStore.fetchContact(radioID: testDeviceID, publicKey: key) != nil) + } + } } // MARK: - Test Helpers diff --git a/MeshCore/Sources/MeshCore/Protocols/ContactSessionOps.swift b/MeshCore/Sources/MeshCore/Protocols/ContactSessionOps.swift index e6d861da1..9775045a8 100644 --- a/MeshCore/Sources/MeshCore/Protocols/ContactSessionOps.swift +++ b/MeshCore/Sources/MeshCore/Protocols/ContactSessionOps.swift @@ -9,6 +9,17 @@ public protocol ContactSessionOps: Actor { /// - Throws: `MeshCoreError` if the contact query fails. func getContacts(since lastModified: Date?) async throws -> [MeshContact] + /// Retrieves contacts and the device's reported contact total. + /// + /// The total comes from the `contactsStart` header. On a full fetch + /// (`since == nil`) it is the device's complete contact count, so a caller + /// that prunes local rows can detect a truncated stream. + /// + /// - Parameter lastModified: An optional date for incremental synchronization. + /// - Returns: The contacts and the reported total. + /// - Throws: `MeshCoreError` if the contact query fails. + func getContactsReportingTotal(since lastModified: Date?) async throws -> ContactFetchResult + /// Fetches a single contact from the device by public key. /// /// - Parameter publicKey: The full 32-byte public key of the contact. diff --git a/MeshCore/Sources/MeshCore/Session/ContactFetchResult.swift b/MeshCore/Sources/MeshCore/Session/ContactFetchResult.swift new file mode 100644 index 000000000..11f8aacaf --- /dev/null +++ b/MeshCore/Sources/MeshCore/Session/ContactFetchResult.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A contact fetch paired with the device's reported contact total. +/// +/// The device sends the total in the `contactsStart` header at the start of a +/// `GET_CONTACTS` reply. On a full fetch (`since == nil`) the total is the +/// device's complete contact count, so a caller that prunes local rows can +/// compare it to `contacts.count` and skip the prune on a truncated stream. +/// +/// `reportedTotal` is `nil` when the reply completed without a `contactsStart` +/// header. The total is then unknown, so a prune caller must not treat the +/// received set as complete. +public struct ContactFetchResult: Sendable { + /// The contacts received in the reply. + public let contacts: [MeshContact] + + /// The contact total the device reported in the `contactsStart` header, or + /// `nil` if the header never arrived. + public let reportedTotal: Int? + + public init(contacts: [MeshContact], reportedTotal: Int?) { + self.contacts = contacts + self.reportedTotal = reportedTotal + } +} diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession+Contacts.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession+Contacts.swift index 8ebccba4d..dfe34096f 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession+Contacts.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession+Contacts.swift @@ -101,7 +101,29 @@ public extension MeshCoreSession { /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. func getContacts(since lastModified: Date? = nil) async throws -> [MeshContact] { - let (contacts, modifiedDate): ([MeshContact], Date?) = try await requestResponseSerializer.withSerialization { [self] in + try await fetchContacts(since: lastModified).contacts + } + + /// Fetches contacts and the device's reported contact total. + /// + /// The total comes from the `contactsStart` header. On a full fetch + /// (`since == nil`) it is the device's complete contact count, so a prune + /// caller can skip when received count is below that total. On an incremental + /// fetch the header still reports the full total, so the count is only a + /// completeness signal when `since == nil`. + /// + /// - Parameter lastModified: If provided, only returns contacts modified after this date. + /// - Returns: The contacts and the reported total. + /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. + func getContactsReportingTotal(since lastModified: Date? = nil) async throws -> ContactFetchResult { + let result = try await fetchContacts(since: lastModified) + return ContactFetchResult(contacts: result.contacts, reportedTotal: result.reportedTotal) + } + + private func fetchContacts( + since lastModified: Date? + ) async throws -> (contacts: [MeshContact], modifiedDate: Date?, reportedTotal: Int?) { + let (contacts, modifiedDate, reportedTotal): ([MeshContact], Date?, Int?) = try await requestResponseSerializer.withSerialization { [self] in let data = PacketBuilder.getContacts(since: lastModified) let (subscriptionID, events) = await dispatcher.subscribeTracked() @@ -114,12 +136,13 @@ public extension MeshCoreSession { // 3. Defers contactManager mutations until after the serialization closure // to avoid actor-isolation issues in the @Sendable closure. return try await withThrowingTaskGroup( - of: ([MeshContact], Date?).self + of: ([MeshContact], Date?, Int?).self ) { group in let progressTracker = StreamProgressTracker() group.addTask { var receivedContacts: [MeshContact] = [] var finalModifiedDate: Date? + var reportedTotal: Int? for await event in events { if Task.isCancelled { @@ -129,6 +152,7 @@ public extension MeshCoreSession { switch event { case let .contactsStart(count): await progressTracker.markProgress() + reportedTotal = count receivedContacts.reserveCapacity(count) case let .contact(contact): await progressTracker.markProgress() @@ -136,7 +160,7 @@ public extension MeshCoreSession { case let .contactsEnd(modifiedDate): await progressTracker.markProgress() finalModifiedDate = modifiedDate - return (receivedContacts, finalModifiedDate) + return (receivedContacts, finalModifiedDate, reportedTotal) case let .error(code): throw MeshCoreError.deviceError(code: code ?? 0) default: @@ -197,7 +221,7 @@ public extension MeshCoreSession { contactManager.markClean(lastModified: modifiedDate) } - return contacts + return (contacts, modifiedDate, reportedTotal) } /// Fetches a single contact from the device by public key. diff --git a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift index 786c30d85..714887f7a 100644 --- a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift @@ -263,6 +263,84 @@ struct MeshCoreSessionCommandCorrelationTests { await session.stop() } + @Test + func `getContactsReportingTotal surfaces the contactsStart total, not the received count`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration( + defaultTimeout: 10, + clientIdentifier: "MCTst", + contactStreamInactivityTimeout: 1.0, + contactStreamHardTimeout: 10.0 + ) + ) + + try await startSession(session, transport: transport) + + let fetchTask = Task { + try await session.getContactsReportingTotal() + } + + try await waitUntil("getContacts should be sent") { + await transport.sentData.count == 2 + } + + // Header reports 3, but the stream carries only 2 contacts before end. The + // reported total must be the header value so the prune caller can detect the + // truncation. + await transport.simulateReceive(makeContactsStartPacket(count: 3)) + for index in 0..<2 { + await transport.simulateReceive( + makeContactPacket(publicKey: Data(repeating: UInt8(index + 1), count: 32), name: "Node \(index)") + ) + } + await transport.simulateReceive(makeContactsEndPacket(lastModified: 1_704_067_200)) + + let result = try await fetchTask.value + #expect(result.contacts.count == 2) + #expect(result.reportedTotal == 3) + await session.stop() + } + + @Test + func `getContactsReportingTotal returns a nil total when contactsStart never arrives`() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration( + defaultTimeout: 10, + clientIdentifier: "MCTst", + contactStreamInactivityTimeout: 1.0, + contactStreamHardTimeout: 10.0 + ) + ) + + try await startSession(session, transport: transport) + + let fetchTask = Task { + try await session.getContactsReportingTotal() + } + + try await waitUntil("getContacts should be sent") { + await transport.sentData.count == 2 + } + + // Contacts and end arrive with no start header. The total stays unknown so a + // prune caller cannot mistake this stream for a complete snapshot. + for index in 0..<2 { + await transport.simulateReceive( + makeContactPacket(publicKey: Data(repeating: UInt8(index + 1), count: 32), name: "Node \(index)") + ) + } + await transport.simulateReceive(makeContactsEndPacket(lastModified: 1_704_067_200)) + + let result = try await fetchTask.value + #expect(result.contacts.count == 2) + #expect(result.reportedTotal == nil) + await session.stop() + } + @Test func `getContacts times out after inactivity before contactsEnd`() async throws { let transport = MockTransport() From 570d27a82d260feb02b3ff7bb2140ce77f876694 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:26:48 -0700 Subject: [PATCH 05/47] Bump version to 1.4.0 --- project.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/project.yml b/project.yml index a49c94579..a27ad9a22 100644 --- a/project.yml +++ b/project.yml @@ -79,7 +79,7 @@ targets: base: SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: YES ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 1.3.0 + MARKETING_VERSION: 1.4.0 CURRENT_PROJECT_VERSION: 1 VERSIONING_SYSTEM: apple-generic TARGETED_DEVICE_FAMILY: "1,2" @@ -129,7 +129,7 @@ targets: settings: base: INFOPLIST_FILE: MC1Tests/Info.plist - MARKETING_VERSION: 1.3.0 + MARKETING_VERSION: 1.4.0 CURRENT_PROJECT_VERSION: 1 VERSIONING_SYSTEM: apple-generic TEST_HOST: "$(BUILT_PRODUCTS_DIR)/MC1.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MC1" @@ -143,7 +143,7 @@ targets: - path: Shared settings: base: - MARKETING_VERSION: 1.3.0 + MARKETING_VERSION: 1.4.0 CURRENT_PROJECT_VERSION: 1 VERSIONING_SYSTEM: apple-generic TARGETED_DEVICE_FAMILY: "1,2" From 9d4603403f4ed716240a2186d5608b4355a8b80b Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:44:45 -0700 Subject: [PATCH 06/47] feat(contacts): stamp lastHeard on mesh liveness - Stamp lastHeard on inbound DM, successful ping, and path response - Keep radio lastModified on path response - Cover DM and path response with tests Co-authored-by: JCBird1012 --- MC1/Utilities/PingHelper.swift | 12 ++ .../Sources/MC1Services/Models/Contact.swift | 5 +- .../Services/AdvertisementService.swift | 17 ++- .../SyncCoordinator+MessageHandlers.swift | 15 +++ .../AdvertisementServiceTests.swift | 79 +++++++++++++ .../SyncCoordinatorMessageHandlerTests.swift | 104 ++++++++++++++++++ 6 files changed, 228 insertions(+), 4 deletions(-) diff --git a/MC1/Utilities/PingHelper.swift b/MC1/Utilities/PingHelper.swift index cf5c335ce..1455e5c56 100644 --- a/MC1/Utilities/PingHelper.swift +++ b/MC1/Utilities/PingHelper.swift @@ -68,6 +68,18 @@ enum PingHelper { let elapsed = ContinuousClock.now - startTime let latencyMs = Int(elapsed / .milliseconds(1)) + if let radioID = appState.connectedDevice?.radioID { + do { + _ = try await services.dataStore.touchContactHeard( + radioID: radioID, + publicKey: contact.publicKey, + at: Date() + ) + } catch { + logger.error("Ping lastHeard stamp failed: \(error.localizedDescription)") + } + } + let announcement = L10n.Contacts.Contacts.Detail.pingSuccessAnnouncement(latencyMs) AccessibilityNotification.Announcement(announcement).post() return .success(latencyMs: latencyMs, snrThere: snrThere, snrBack: snrBack) diff --git a/MC1Services/Sources/MC1Services/Models/Contact.swift b/MC1Services/Sources/MC1Services/Models/Contact.swift index 5ca958417..af50041a9 100644 --- a/MC1Services/Sources/MC1Services/Models/Contact.swift +++ b/MC1Services/Sources/MC1Services/Models/Contact.swift @@ -50,8 +50,9 @@ public final class Contact { /// Last modification timestamp (for sync watermarking) public var lastModified: UInt32 - /// Phone-clock epoch seconds of the last on-air evidence this phone heard - /// for the contact. Monotonic; 0 means never heard by this phone. + /// Phone-clock epoch seconds of the last mesh liveness evidence this phone + /// recorded for the contact (advert receive, inbound DM, successful ping, + /// path-discovery response). Monotonic; 0 means never heard by this phone. public var lastHeardTimestamp: UInt32 = 0 /// Local nickname override (optional) diff --git a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift index 12acb72da..c43a7638d 100644 --- a/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift +++ b/MC1Services/Sources/MC1Services/Services/AdvertisementService.swift @@ -493,7 +493,8 @@ public actor AdvertisementService { scheduleDeltaSync() } - /// Handle path discovery response event + /// Path discovery response: update out-path and stamp phone-clock lastHeard. + /// Leaves radio lastModified unchanged — it is a radio watermark, not phone time. 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 @@ -525,9 +526,21 @@ public actor AdvertisementService { lastAdvertTimestamp: contact.lastAdvertTimestamp, latitude: contact.latitude, longitude: contact.longitude, - lastModified: UInt32(Date().timeIntervalSince1970) + lastModified: contact.lastModified ) _ = try await dataStore.saveContact(radioID: radioID, from: frame) + + do { + _ = try await dataStore.touchContactHeard( + radioID: radioID, + publicKey: contact.publicKey, + at: Date() + ) + } catch { + logger.error( + "Path response lastHeard stamp failed: \(error.localizedDescription)" + ) + } } eventBroadcaster.yield(.pathDiscoveryResponse(result)) diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift index 6eb5787b9..599ea0297 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift @@ -247,6 +247,21 @@ extension SyncCoordinator { logger.warning("Dedup check failed, proceeding with save: \(error)") } + // Stamp before the reaction early return so bodies and reactions both count. + if case let .direct(_, contact) = resolvedKind, let contact { + do { + _ = try await dependencies.dataStore.touchContactHeard( + radioID: radioID, + publicKey: contact.publicKey, + at: Date() + ) + } catch { + logger.error( + "lastHeard stamp failed for inbound DM: \(error.localizedDescription)" + ) + } + } + switch resolvedKind { case let .direct(_, contact): // Check if this is a DM reaction diff --git a/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift index 52863a834..22a4ed22d 100644 --- a/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/AdvertisementServiceTests.swift @@ -2258,6 +2258,85 @@ struct AdvertisementServiceTests { #expect(favorite.recencyTimestamp < cutoff) #expect(!favorite.matchesStaleNodePrune(cutoff: cutoff)) } + + // MARK: - Path discovery response lastHeard + + @Test + func `pathResponse stamps lastHeard and preserves radio lastModified`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + + let key = makePublicKey(seed: 0xA5) + let radioLastMod: UInt32 = 1_700_000_100 + _ = try await store.saveContact( + radioID: radioID, + from: makeContactFrame( + publicKey: key, + name: "PathPeer", + lastModified: radioLastMod + ) + ) + + await startMonitoring(service, session: session) + + let pathInfo = PathInfo( + publicKeyPrefix: Data(key.prefix(6)), + outPathLength: 0, + outPath: Data(), + inPathLength: 0, + inPath: Data() + ) + await session.yieldEvent(.pathResponse(pathInfo)) + + let stamped = await waitUntil { + guard let contact = try? await store.fetchContact(radioID: radioID, publicKey: key) else { + return false + } + return (contact.lastHeardTimestamp ?? 0) > 0 + } + #expect(stamped) + + let updated = try #require( + await store.fetchContact(radioID: radioID, publicKey: key) + ) + #expect(updated.lastModified == radioLastMod) + #expect((updated.lastHeardTimestamp ?? 0) > 0) + + await service.stopEventMonitoring() + } + + @Test + func `pathUpdate does not stamp lastHeard`() async throws { + let store = try await makeStore() + let session = MockMeshCoreSession() + let service = makeService(session: session, store: store) + let recorder = HandlerRecorder(store: store, radioID: radioID) + await installHandler(service, recorder: recorder) + + let key = makePublicKey(seed: 0xA6) + _ = try await store.saveContact( + radioID: radioID, + from: makeContactFrame( + publicKey: key, + name: "PathUpdatePeer", + lastModified: 1_700_000_100 + ) + ) + + await startMonitoring(service, session: session) + await session.yieldEvent(.pathUpdate(publicKey: key)) + + let ran = await waitUntil { await recorder.callCount >= 1 } + #expect(ran) + + let updated = try #require( + await store.fetchContact(radioID: radioID, publicKey: key) + ) + #expect((updated.lastHeardTimestamp ?? 0) == 0) + + await service.stopEventMonitoring() + } } // MARK: - Concurrency helpers diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift index 3db4db3e6..643516176 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift @@ -234,4 +234,108 @@ struct SyncCoordinatorMessageHandlerTests { ) #expect(SyncCoordinator.shouldPostChannelNotification(forResolvedChannel: channel) == true) } + + // MARK: - lastHeard mesh-liveness stamps + + @Test + func `inbound DM stamps contact lastHeard`() async throws { + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + let publicKey = Data(repeating: 0xAB, count: 32) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + name: "Peer", + lastHeardTimestamp: 0 + ) + try await dataStore.saveContact(contact) + + let mockPolling = MockMessagePollingService() + let (_, services) = try await createTestServices() + let dependencies = services.syncDependencies + .with(dataStore: dataStore, messagePollingService: mockPolling) + + let coordinator = SyncCoordinator() + await coordinator.wireMessageHandlers(dependencies: dependencies, radioID: radioID) + + let message = ContactMessage( + senderPublicKeyPrefix: Data(publicKey.prefix(6)), + pathLength: 0, + textType: 0, + senderTimestamp: Date(), + signature: nil, + text: "hello mesh", + snr: nil + ) + await mockPolling.capturedContactMessageHandler?(message, contact, .live) + + let updated = try #require( + await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect((updated.lastHeardTimestamp ?? 0) > 0) + } + + @Test + func `inbound channel message does not stamp lastHeard on a contact`() async throws { + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + let publicKey = Data(repeating: 0xCD, count: 32) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + name: "ChannelPeer", + lastHeardTimestamp: 0 + ) + try await dataStore.saveContact(contact) + + let mockPolling = MockMessagePollingService() + let (_, services) = try await createTestServices() + let dependencies = services.syncDependencies + .with(dataStore: dataStore, messagePollingService: mockPolling) + + let coordinator = SyncCoordinator() + await coordinator.wireMessageHandlers(dependencies: dependencies, radioID: radioID) + + let channelMessage = ChannelMessage( + channelIndex: 0, + pathLength: 0, + textType: 0, + senderTimestamp: Date(), + text: "ChannelPeer: hello channel", + snr: nil + ) + await mockPolling.capturedChannelMessageHandler?(channelMessage, nil, .live) + + let updated = try #require( + await dataStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect((updated.lastHeardTimestamp ?? 0) == 0) + } +} + +// MARK: - SyncDependencies test helpers + +extension SyncDependencies { + /// Copy with a different data store and message polling service for tests. + func with( + dataStore: any PersistenceStoreProtocol, + messagePollingService: any MessagePollingServiceProtocol + ) -> 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: startEventMonitoring, + exportPrivateKey: exportPrivateKey + ) + } } From 07d7dd30d7d8df7ae0b8ad9d6906fe1960998ae7 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:12:34 -0700 Subject: [PATCH 07/47] feat(chats): show multi-match flood regions - Store every region that verifies a packet's transport code, not first match only - Surface ambiguous chips in bubbles, message info, and RX log - Reprocess retained rows when known regions change --- MC1/Resources/Generated/L10n.swift | 20 + .../Localization/de.lproj/Chats.strings | 18 + .../Localization/en.lproj/Chats.strings | 18 + .../Localization/es.lproj/Chats.strings | 18 + .../Localization/fr.lproj/Chats.strings | 18 + .../Localization/it.lproj/Chats.strings | 18 + .../Localization/nl.lproj/Chats.strings | 20 + .../Localization/pl.lproj/Chats.strings | 20 + .../Localization/ru.lproj/Chats.strings | 20 + .../Localization/uk.lproj/Chats.strings | 20 + .../Localization/zh-Hans.lproj/Chats.strings | 18 + MC1/State/MessageEvent.swift | 3 + MC1/State/MessageEventDispatcher.swift | 12 + .../Chats/Components/BubbleFooterRow.swift | 29 +- .../Components/UnifiedMessageBubble.swift | 2 +- .../Sections/ActionsDetailsSection.swift | 20 +- .../Chats/Timeline/ChatTimeline+Paging.swift | 4 + .../ViewModel/ChatViewModel+EventStream.swift | 4 + .../Rooms/RoomConversationViewModel.swift | 2 +- MC1/Views/Tools/RxLogView.swift | 13 +- .../Sources/MC1Services/Models/Message.swift | 30 +- .../Models/RegionScopeSemantics.swift | 98 ++++ .../Models/Rendering/MessageFooter.swift | 14 +- .../MC1Services/Models/RxLogEntry.swift | 21 +- .../Persistence/RxLogPersisting.swift | 58 ++- .../Services/MessageFragmentBuilder.swift | 19 +- .../PersistenceStore+Diagnostics.swift | 64 ++- .../RxLogService+RegionResolution.swift | 221 +++++--- .../MC1Services/Services/RxLogService.swift | 54 +- .../Sync/SyncCoordinator+HandlerHelpers.swift | 28 +- .../SyncCoordinator+MessageHandlers.swift | 3 +- .../BackupIntegrationTests.swift | 64 +++ .../Mocks/MockPersistenceStore.swift | 31 +- .../Models/RegionScopeSemanticsTests.swift | 81 +++ .../PersistenceStoreTests.swift | 118 ++++- .../RxLogServiceRegionReprocessTests.swift | 485 ++++++++++++++++++ .../SyncCoordinatorMessageHandlerTests.swift | 62 +++ .../Services/InlineImagePrefetcherTests.swift | 15 +- MC1Tests/Services/LinkPreviewCacheTests.swift | 15 +- .../LineOfSightViewModelTests.swift | 15 +- .../Chats/ChatViewModelAdmissionTests.swift | 15 +- .../MessageBubblePredicateTests.swift | 68 ++- .../Tools/CLI/CLIToolViewModelTests.swift | 16 +- .../TransportCodeRegionResolver.swift | 46 +- .../TransportCodeRegionResolverTests.swift | 62 ++- 45 files changed, 1794 insertions(+), 206 deletions(-) create mode 100644 MC1Services/Sources/MC1Services/Models/RegionScopeSemantics.swift create mode 100644 MC1Services/Tests/MC1ServicesTests/Models/RegionScopeSemanticsTests.swift create mode 100644 MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceRegionReprocessTests.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index d3195ebd9..b6e72315f 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -684,6 +684,10 @@ public enum L10n { public static func received(_ p1: Any) -> String { return L10n.tr("Chats", "chats.message.info.received", String(describing: p1), fallback: "Received: %@") } + /// Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names + public static func regionAmbiguous(_ p1: Any) -> String { + return L10n.tr("Chats", "chats.message.info.regionAmbiguous", String(describing: p1), fallback: "Region (ambiguous): %@") + } /// Location: MessageActionsSheet.swift - Details row shown when region could not be resolved public static let regionUnresolved = L10n.tr("Chats", "chats.message.info.regionUnresolved", fallback: "Region: Unknown") /// Location: UnifiedMessageBubble.swift - Context menu text showing round trip time - %d is milliseconds @@ -714,6 +718,22 @@ public enum L10n { public static func accessibilityLabel(_ p1: Any) -> String { return L10n.tr("Chats", "chats.message.region.accessibilityLabel", String(describing: p1), fallback: "Region %@") } + /// Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names + public static func ambiguousAccessibilityLabel(_ p1: Any) -> String { + return L10n.tr("Chats", "chats.message.region.ambiguousAccessibilityLabel", String(describing: p1), fallback: "Region, ambiguous: %@") + } + public enum Ambiguous { + /// Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names + public static func popoverBody(_ p1: Any) -> String { + return L10n.tr("Chats", "chats.message.region.ambiguous.popoverBody", String(describing: p1), fallback: "More than one region in your list matches this packet’s transport code. The app cannot tell which one was used.%@") + } + /// Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match + public static let popoverTitle = L10n.tr("Chats", "chats.message.region.ambiguous.popoverTitle", fallback: "Multiple matching regions") + /// Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match + public static let possibleMatch = L10n.tr("Chats", "chats.message.region.ambiguous.possibleMatch", fallback: "Multiple matching regions") + /// Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match + public static let possibleMatchHint = L10n.tr("Chats", "chats.message.region.ambiguous.possibleMatchHint", fallback: "More than one region in your list matches this packet’s transport code. The app cannot tell which one was used.") + } } public enum Repeat { /// Location: UnifiedMessageBubble.swift - Plural form of repeats diff --git a/MC1/Resources/Localization/de.lproj/Chats.strings b/MC1/Resources/Localization/de.lproj/Chats.strings index 1ce3eb91c..24f8fcd4e 100644 --- a/MC1/Resources/Localization/de.lproj/Chats.strings +++ b/MC1/Resources/Localization/de.lproj/Chats.strings @@ -612,6 +612,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Region: Unbekannt"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Region (mehrdeutig): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Details"; @@ -899,6 +902,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Region %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Region, mehrdeutig: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Mehrere passende Regionen"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Mehrere Regionen in deiner Liste passen zum Transportcode dieses Pakets. Die App kann nicht feststellen, welche verwendet wurde."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Mehrere passende Regionen"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Mehrere Regionen in deiner Liste passen zum Transportcode dieses Pakets. Die App kann nicht feststellen, welche verwendet wurde.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Sendezeit: %@"; diff --git a/MC1/Resources/Localization/en.lproj/Chats.strings b/MC1/Resources/Localization/en.lproj/Chats.strings index ab8cffc8a..08d1fb6d9 100644 --- a/MC1/Resources/Localization/en.lproj/Chats.strings +++ b/MC1/Resources/Localization/en.lproj/Chats.strings @@ -641,6 +641,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Region: Unknown"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Region (ambiguous): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Details"; @@ -748,6 +751,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name */ "chats.message.region.accessibilityLabel" = "Region %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Region, ambiguous: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Multiple matching regions"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "More than one region in your list matches this packet’s transport code. The app cannot tell which one was used."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Multiple matching regions"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "More than one region in your list matches this packet’s transport code. The app cannot tell which one was used.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Send time: %@"; diff --git a/MC1/Resources/Localization/es.lproj/Chats.strings b/MC1/Resources/Localization/es.lproj/Chats.strings index 7ab934290..31da99421 100644 --- a/MC1/Resources/Localization/es.lproj/Chats.strings +++ b/MC1/Resources/Localization/es.lproj/Chats.strings @@ -612,6 +612,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Región: Desconocido"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Región (ambigua): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Detalles"; @@ -899,6 +902,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Región %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Región, ambigua: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Varias regiones coincidentes"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Más de una región de tu lista coincide con el código de transporte de este paquete. La app no puede determinar cuál se usó."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Varias regiones coincidentes"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Más de una región de tu lista coincide con el código de transporte de este paquete. La app no puede determinar cuál se usó.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Hora de envío: %@"; diff --git a/MC1/Resources/Localization/fr.lproj/Chats.strings b/MC1/Resources/Localization/fr.lproj/Chats.strings index 12784f288..8860e4741 100644 --- a/MC1/Resources/Localization/fr.lproj/Chats.strings +++ b/MC1/Resources/Localization/fr.lproj/Chats.strings @@ -612,6 +612,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Région : Inconnue"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Région (ambiguë) : %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Détails"; @@ -899,6 +902,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Région %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Région, ambiguë : %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Plusieurs régions correspondantes"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Plusieurs régions de votre liste correspondent au code de transport de ce paquet. L’app ne peut pas déterminer laquelle a été utilisée."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Plusieurs régions correspondantes"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Plusieurs régions de votre liste correspondent au code de transport de ce paquet. L’app ne peut pas déterminer laquelle a été utilisée.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Heure d'envoi: %@"; diff --git a/MC1/Resources/Localization/it.lproj/Chats.strings b/MC1/Resources/Localization/it.lproj/Chats.strings index 091355eef..ab564040d 100644 --- a/MC1/Resources/Localization/it.lproj/Chats.strings +++ b/MC1/Resources/Localization/it.lproj/Chats.strings @@ -641,6 +641,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Region: Sconosciuta"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Region (ambigua): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Dettagli"; @@ -748,6 +751,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name */ "chats.message.region.accessibilityLabel" = "Region %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Region, ambigua: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Più region corrispondenti"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Più region nell'elenco corrispondono al codice di trasporto di questo pacchetto. L'app non può stabilire quale sia stata usata."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Più region corrispondenti"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Più region nell'elenco corrispondono al codice di trasporto di questo pacchetto. L'app non può stabilire quale sia stata usata.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Ora di invio: %@"; diff --git a/MC1/Resources/Localization/nl.lproj/Chats.strings b/MC1/Resources/Localization/nl.lproj/Chats.strings index 87a4f4af1..bf03feb60 100644 --- a/MC1/Resources/Localization/nl.lproj/Chats.strings +++ b/MC1/Resources/Localization/nl.lproj/Chats.strings @@ -612,6 +612,10 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Regio: Onbekend"; +/* TODO: translate */ +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Regio (dubbelzinnig): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Details"; @@ -899,6 +903,22 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Regio %@"; +/* TODO: translate */ +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Regio, dubbelzinnig: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Meerdere overeenkomende regio's"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Meer dan één regio in je lijst komt overeen met de transportcode van dit pakket. De app kan niet bepalen welke is gebruikt."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Meerdere overeenkomende regio's"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Meer dan één regio in je lijst komt overeen met de transportcode van dit pakket. De app kan niet bepalen welke is gebruikt.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Verzendtijd: %@"; diff --git a/MC1/Resources/Localization/pl.lproj/Chats.strings b/MC1/Resources/Localization/pl.lproj/Chats.strings index 3b8830f2b..2600939ef 100644 --- a/MC1/Resources/Localization/pl.lproj/Chats.strings +++ b/MC1/Resources/Localization/pl.lproj/Chats.strings @@ -607,6 +607,10 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Region: Nieznany"; +/* TODO: translate */ +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Region (niejednoznaczny): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Szczegóły"; @@ -891,6 +895,22 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Region %@"; +/* TODO: translate */ +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Region, niejednoznaczny: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Wiele pasujących regionów"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Więcej niż jeden region na Twojej liście pasuje do kodu transportowego tego pakietu. Aplikacja nie może określić, który został użyty."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Wiele pasujących regionów"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Więcej niż jeden region na Twojej liście pasuje do kodu transportowego tego pakietu. Aplikacja nie może określić, który został użyty.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Czas wysłania: %@"; diff --git a/MC1/Resources/Localization/ru.lproj/Chats.strings b/MC1/Resources/Localization/ru.lproj/Chats.strings index 0ea7adf3c..c33d5965e 100644 --- a/MC1/Resources/Localization/ru.lproj/Chats.strings +++ b/MC1/Resources/Localization/ru.lproj/Chats.strings @@ -606,6 +606,10 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Регион: Неизвестно"; +/* TODO: translate */ +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Регион (неоднозначный): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Подробнее"; @@ -890,6 +894,22 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Регион %@"; +/* TODO: translate */ +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Регион, неоднозначный: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Несколько совпадающих регионов"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Более одного региона в вашем списке соответствует транспортному коду этого пакета. Приложение не может определить, какой из них использовался."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Несколько совпадающих регионов"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Более одного региона в вашем списке соответствует транспортному коду этого пакета. Приложение не может определить, какой из них использовался.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Время отправки: %@"; diff --git a/MC1/Resources/Localization/uk.lproj/Chats.strings b/MC1/Resources/Localization/uk.lproj/Chats.strings index affab7508..1f9d629fd 100644 --- a/MC1/Resources/Localization/uk.lproj/Chats.strings +++ b/MC1/Resources/Localization/uk.lproj/Chats.strings @@ -606,6 +606,10 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "Регіон: Невідомо"; +/* TODO: translate */ +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Регіон (неоднозначний): %@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "Докладніше"; @@ -890,6 +894,22 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "Регіон %@"; +/* TODO: translate */ +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Регіон, неоднозначний: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Кілька відповідних регіонів"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Кілька регіонів у вашому списку відповідають транспортному коду цього пакета. Застосунок не може визначити, який саме використано."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Кілька відповідних регіонів"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Кілька регіонів у вашому списку відповідають транспортному коду цього пакета. Застосунок не може визначити, який саме використано.%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Час надсилання: %@"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings index 83ee22b88..899fb8c9a 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings @@ -641,6 +641,9 @@ /* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ "chats.message.info.regionUnresolved" = "区域:未知"; +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "区域(有歧义):%@"; + /* Location: UnifiedMessageBubble.swift - Context menu submenu label */ "chats.message.action.details" = "详情"; @@ -748,6 +751,21 @@ /* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name. "Flooded" is a domain term for mesh-broadcast across all nodes in a radio region. */ "chats.message.region.accessibilityLabel" = "区域 %@"; +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "区域,有歧义:%@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "多个匹配区域"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "您的列表中有多个区域与此数据包的传输代码匹配。应用无法确定使用了哪一个。"; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "多个匹配区域"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "您的列表中有多个区域与此数据包的传输代码匹配。应用无法确定使用了哪一个。%@"; + /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "发送时间:%@"; diff --git a/MC1/State/MessageEvent.swift b/MC1/State/MessageEvent.swift index b3593a5b6..fbb379054 100644 --- a/MC1/State/MessageEvent.swift +++ b/MC1/State/MessageEvent.swift @@ -30,6 +30,9 @@ enum MessageEvent: Equatable { case messageRetrying(messageID: UUID, attempt: Int, maxAttempts: Int) case heardRepeatRecorded(messageID: UUID, count: Int) case reactionReceived(messageID: UUID, summary: String) + /// Region reprocess rewrote dual region fields on these Message rows. + /// Consumers call `enqueueReload` so open chats re-bake region footers. + case messagesRegionUpdated(messageIDs: [UUID]) 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 226a3ad37..2d16bbcf3 100644 --- a/MC1/State/MessageEventDispatcher.swift +++ b/MC1/State/MessageEventDispatcher.swift @@ -32,6 +32,7 @@ final class MessageEventDispatcher { cancelAll() wireSyncCoordinator(services.syncCoordinator) wireHeardRepeats(services.heardRepeatsService) + wireRxLogRegionUpdates(services.rxLogService) wireRemoteNode(services.remoteNodeService) wireRoomServer(services.roomServerService) wireMessageService(services.messageService) @@ -85,6 +86,17 @@ final class MessageEventDispatcher { tasks.append(task) } + private func wireRxLogRegionUpdates(_ rxLogService: RxLogService) { + let events = rxLogService.regionUpdateEvents() + let task = Task { [stream] in + for await messageIDs in events { + guard !messageIDs.isEmpty else { continue } + stream.send(.messagesRegionUpdated(messageIDs: messageIDs)) + } + } + tasks.append(task) + } + private func wireRemoteNode(_ remoteNodeService: RemoteNodeService) { let events = remoteNodeService.events() let task = Task { [weak appState] in diff --git a/MC1/Views/Chats/Components/BubbleFooterRow.swift b/MC1/Views/Chats/Components/BubbleFooterRow.swift index ce06245e6..02e6bf031 100644 --- a/MC1/Views/Chats/Components/BubbleFooterRow.swift +++ b/MC1/Views/Chats/Components/BubbleFooterRow.swift @@ -78,6 +78,7 @@ struct BubbleFooterRow: View { if let region = footer.regionToShow { badges.append(AnyView(BubbleRegionFooter( regionName: region, + matchNames: footer.regionMatchNames, allowsWrap: dynamicTypeSize.isAccessibilitySize ))) } @@ -167,19 +168,45 @@ private struct BubblePathFooter: View { private struct BubbleRegionFooter: View { let regionName: String + let matchNames: [String] let allowsWrap: Bool + private var isAmbiguous: Bool { + matchNames.count > 1 + } + var body: some View { HStack(spacing: 2) { Image(systemName: "globe") Text(regionName) .lineLimit(allowsWrap ? nil : 1) + if isAmbiguous { + // Explicit region L10n only; do not use path-hop default strings. + let listBody = "\n" + matchNames.joined(separator: "\n") + FallbackMatchIndicatorView( + accessibilityLabel: L10n.Chats.Chats.Message.Region.Ambiguous.possibleMatch, + accessibilityHint: L10n.Chats.Chats.Message.Region.Ambiguous.possibleMatchHint, + title: L10n.Chats.Chats.Message.Region.Ambiguous.popoverTitle, + explanation: L10n.Chats.Chats.Message.Region.Ambiguous.popoverBody(listBody) + ) + } } .font(.caption2) .foregroundStyle(.secondary) .footerChip(color: .secondary) .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Message.Region.accessibilityLabel(regionName)) + .accessibilityLabel(MessageRegionAccessibility.label(regionName: regionName, matchNames: matchNames)) + } +} + +/// Shared VoiceOver label for the region chip and the whole-bubble combined label. +enum MessageRegionAccessibility { + static func label(regionName: String, matchNames: [String]) -> String { + if matchNames.count > 1 { + let list = ListFormatter.localizedString(byJoining: matchNames) + return L10n.Chats.Chats.Message.Region.ambiguousAccessibilityLabel(list) + } + return L10n.Chats.Chats.Message.Region.accessibilityLabel(regionName) } } diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index 5076ed1c6..74d92cae0 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -325,7 +325,7 @@ struct UnifiedMessageBubble: View, Equatable { label += ", \(L10n.Chats.Chats.Message.Path.accessibilityLabel(formattedPath))" } if let region = item.footer.regionToShow { - label += ", \(L10n.Chats.Chats.Message.Region.accessibilityLabel(region))" + label += ", \(MessageRegionAccessibility.label(regionName: region, matchNames: item.footer.regionMatchNames))" } } return label diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift index e722eb19a..1eede52f3 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsDetailsSection.swift @@ -190,11 +190,21 @@ private struct ActionsIncomingDetailsRows: View { } 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 regionText: String = { + switch RegionScopeSemantics.coalesce( + scope: message.regionScope, + matches: message.regionScopeMatches + ) { + case .none: + return L10n.Chats.Chats.Message.Info.regionUnresolved + case let .unique(name): + return L10n.Chats.Chats.Message.Info.floodedUnder(name) + case let .ambiguous(names): + let list = ListFormatter.localizedString(byJoining: names) + return L10n.Chats.Chats.Message.Info.regionAmbiguous(list) + } + }() + ActionInfoRow(text: regionText, icon: "globe") } let sentText = L10n.Chats.Chats.Message.Info.sent( diff --git a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift index 55c6202ca..f4e07d63b 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift @@ -160,6 +160,10 @@ extension ChatTimeline { writer?.enqueueReload(messageID: messageID) } + func enqueueReload(updatedMessageIDs: Set) { + writer?.enqueueReload(updatedMessageIDs: updatedMessageIDs) + } + /// Removes a message and its render item together. func removeMessage(_ messageID: UUID) { writer?.remove(messageID: messageID) diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift index 00e0f710e..d67a801a8 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift @@ -53,6 +53,10 @@ extension ChatViewModel { let .reactionReceived(messageID, _): timeline.enqueueReload(messageID: messageID) + case let .messagesRegionUpdated(messageIDs): + // Region reprocess rewrote dual fields; re-fetch to re-bake region chips. + timeline.enqueueReload(updatedMessageIDs: Set(messageIDs)) + case let .routingChanged(contactID, _): guard let current = currentContact, current.id == contactID else { return } requestContactRefresh() diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift index 5ffa7b78b..9c077ec3a 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift @@ -176,7 +176,7 @@ final class RoomConversationViewModel { case .directMessageReceived, .channelMessageReceived, .messageStatusResolved, .messageResent, .messageFailed, .messageRetrying, - .heardRepeatRecorded, .reactionReceived, .routingChanged: + .heardRepeatRecorded, .reactionReceived, .messagesRegionUpdated, .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. diff --git a/MC1/Views/Tools/RxLogView.swift b/MC1/Views/Tools/RxLogView.swift index 78772ad2b..7cee64e83 100644 --- a/MC1/Views/Tools/RxLogView.swift +++ b/MC1/Views/Tools/RxLogView.swift @@ -449,9 +449,20 @@ struct RxLogRowView: View { DetailRow(label: L10n.Tools.Tools.RxLog.channelNameLabel, value: channelName) } if let transportCode = entry.transportCode, !transportCode.isEmpty { + let regionValue: String = switch RegionScopeSemantics.coalesce( + scope: entry.regionScope, + matches: entry.regionScopeMatches + ) { + case .none: + L10n.Tools.Tools.RxLog.regionUnresolved + case let .unique(name): + name + case let .ambiguous(names): + ListFormatter.localizedString(byJoining: names) + } DetailRow( label: L10n.Tools.Tools.RxLog.regionLabel, - value: entry.regionScope ?? L10n.Tools.Tools.RxLog.regionUnresolved + value: regionValue ) } if let text = entry.decodedText { diff --git a/MC1Services/Sources/MC1Services/Models/Message.swift b/MC1Services/Sources/MC1Services/Models/Message.swift index 135a85acc..f92bcac49 100644 --- a/MC1Services/Sources/MC1Services/Models/Message.swift +++ b/MC1Services/Sources/MC1Services/Models/Message.swift @@ -149,12 +149,15 @@ public final class Message { /// 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`). + /// Confident single flood-region name from RxLog correlation, or nil when + /// unresolved or ambiguous. Read through `RegionScopeSemantics.coalesce` + /// with `regionScopeMatches` — nil alone is not "Unknown." public var regionScope: String? + /// Sorted known public regions that verify this packet's transport code. + /// Empty, one name, or two-plus for multi-match. Defaults to `[]`. + public var regionScopeMatches: [String] = [] + /// Heard repeats for this message (cascade delete) @Relationship(deleteRule: .cascade, inverse: \MessageRepeat.message) var repeats: [MessageRepeat]? @@ -196,7 +199,8 @@ public final class Message { senderTimestamp: UInt32? = nil, reactionSummary: String? = nil, routeTypeRawValue: Int = -1, - regionScope: String? = nil + regionScope: String? = nil, + regionScopeMatches: [String] = [] ) { self.id = id self.radioID = radioID @@ -235,6 +239,7 @@ public final class Message { self.reactionSummary = reactionSummary self.routeTypeRawValue = routeTypeRawValue self.regionScope = regionScope + self.regionScopeMatches = regionScopeMatches } /// Builds a model instance directly from a DTO. Shared by backup batch-insert @@ -279,7 +284,8 @@ public final class Message { senderTimestamp: dto.senderTimestamp, reactionSummary: dto.reactionSummary, routeTypeRawValue: dto.routeType.map { Int($0.rawValue) } ?? -1, - regionScope: dto.regionScope + regionScope: dto.regionScope, + regionScopeMatches: dto.regionScopeMatches ) } } @@ -365,6 +371,8 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { public var reactionSummary: String? public var routeType: RouteType? public var regionScope: String? + /// Sorted multi-match set. Empty when the backup key is missing. + public var regionScopeMatches: [String] /// Explicit Codable so backups predating ``sortDate`` decode cleanly. /// Legacy envelopes have no `sortDate` key; it falls back to `createdAt`, @@ -377,7 +385,8 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { roundTripTime, heardRepeats, sendCount, retryAttempt, maxRetryAttempts, deduplicationKey, linkPreviewURL, linkPreviewTitle, linkPreviewImageData, linkPreviewIconData, linkPreviewFetched, containsSelfMention, mentionSeen, - timestampCorrected, senderTimestamp, reactionSummary, routeType, regionScope + timestampCorrected, senderTimestamp, reactionSummary, routeType, regionScope, + regionScopeMatches } public init(from decoder: Decoder) throws { @@ -420,6 +429,8 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { reactionSummary = try container.decodeIfPresent(String.self, forKey: .reactionSummary) routeType = try container.decodeIfPresent(RouteType.self, forKey: .routeType) regionScope = try container.decodeIfPresent(String.self, forKey: .regionScope) + // Missing key → []; do not invent matches from regionScope. + regionScopeMatches = try container.decodeIfPresent([String].self, forKey: .regionScopeMatches) ?? [] } public init(from message: Message, includeLinkPreviewBlobs: Bool = true) { @@ -470,6 +481,7 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { routeType = UInt8(exactly: message.routeTypeRawValue) .flatMap(RouteType.init(rawValue:)) regionScope = message.regionScope + regionScopeMatches = message.regionScopeMatches } /// Memberwise initializer for creating DTOs directly @@ -510,7 +522,8 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { senderTimestamp: UInt32? = nil, reactionSummary: String? = nil, routeType: RouteType? = nil, - regionScope: String? = nil + regionScope: String? = nil, + regionScopeMatches: [String] = [] ) { self.id = id self.radioID = radioID @@ -549,6 +562,7 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { self.reactionSummary = reactionSummary self.routeType = routeType self.regionScope = regionScope + self.regionScopeMatches = regionScopeMatches } public var isOutgoing: Bool { diff --git a/MC1Services/Sources/MC1Services/Models/RegionScopeSemantics.swift b/MC1Services/Sources/MC1Services/Models/RegionScopeSemantics.swift new file mode 100644 index 000000000..f5655e707 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/RegionScopeSemantics.swift @@ -0,0 +1,98 @@ +import Foundation +import MeshCore + +/// Dual-field read/write helpers for message and RX-log region labeling. +/// +/// MeshCore owns `RegionMatchResult`. This type maps match results to storage +/// fields and coalesces dual fields for readers (including rows that only have +/// `regionScope`). +public enum RegionScopeSemantics { + /// Compact chip join for multi-match labels (e.g. `de-hh / de-by`). + public static let chipNameSeparator = " / " + + /// Map a match result into the two persisted fields. + /// + /// - none → `(nil, [])` + /// - unique → `(name, [name])` + /// - ambiguous → `(nil, sorted names)` — never a single first-match name + public static func storageFields( + from match: RegionMatchResult + ) -> (regionScope: String?, regionScopeMatches: [String]) { + switch match { + case .none: + return (nil, []) + case let .unique(name): + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return (nil, []) } + return (trimmed, [trimmed]) + case let .ambiguous(names): + let filtered = Self.filteredSortedNames(names) + switch filtered.count { + case 0: + return (nil, []) + case 1: + return (filtered[0], filtered) + default: + return (nil, filtered) + } + } + } + + /// Dual-field read. Multi-match wins over a non-nil `regionScope` so a stale + /// single name never surfaces when the match list has two or more entries. + /// + /// Priority: matches ≥ 2 → ambiguous; matches == 1 → unique; empty matches + /// with non-nil scope → unique (legacy); both empty → none. + public static func coalesce( + scope: String?, + matches: [String] + ) -> RegionMatchResult { + let filtered = Self.filteredSortedNames(matches) + switch filtered.count { + case 0: + if let scope { + let trimmed = scope.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? .none : .unique(trimmed) + } + return .none + case 1: + return .unique(filtered[0]) + default: + return .ambiguous(filtered) + } + } + + /// Chip label from a coalesced result. Nil hides the chip. + public static func chipLabel(from match: RegionMatchResult) -> String? { + switch match { + case .none: + nil + case let .unique(name): + name + case let .ambiguous(names): + names.joined(separator: chipNameSeparator) + } + } + + /// Post-filter match names for popover / a11y / Message Info lists. + static func matchNames(from match: RegionMatchResult) -> [String] { + switch match { + case .none: + [] + case let .unique(name): + [name] + case let .ambiguous(names): + names + } + } + + /// Blank-filter then `localizedStandardCompare` ascending, de-duplicated. + private static func filteredSortedNames(_ names: [String]) -> [String] { + let trimmed = names + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return Array(Set(trimmed)).sorted { + $0.localizedStandardCompare($1) == .orderedAscending + } + } +} diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift index 0cb47c371..24e0a053f 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageFooter.swift @@ -14,7 +14,11 @@ public struct MessageFooter: Sendable, Hashable { public let showHop: Bool public let hopCount: Int public let formattedPath: String? + /// Compact chip text; nil hides the region chip. Multi-match is the + /// slash-joined label baked at footer build time. public let regionToShow: String? + /// Candidate region names for popover and accessibility. Count > 1 is ambiguous. + public let regionMatchNames: [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 @@ -38,11 +42,17 @@ public struct MessageFooter: Sendable, Hashable { public let maxRetryAttempts: Int public let sendCount: Int + /// True when `regionMatchNames` has more than one entry. Derived, not stored. + public var regionIsAmbiguous: Bool { + regionMatchNames.count > 1 + } + public init( showHop: Bool, hopCount: Int, formattedPath: String?, regionToShow: String?, + regionMatchNames: [String] = [], sendTimeToShow: Date?, sendTimeWasCorrected: Bool, showStatusRow: Bool, @@ -57,6 +67,7 @@ public struct MessageFooter: Sendable, Hashable { self.hopCount = hopCount self.formattedPath = formattedPath self.regionToShow = regionToShow + self.regionMatchNames = regionMatchNames self.sendTimeToShow = sendTimeToShow self.sendTimeWasCorrected = sendTimeWasCorrected self.showStatusRow = showStatusRow @@ -68,7 +79,7 @@ public struct MessageFooter: Sendable, Hashable { self.sendCount = sendCount } - /// Returns a new footer with `status` overridden. Eliminates the 10-field + /// Returns a new footer with `status` overridden. Eliminates the multi-field /// rebuild at status-flip sites. public func with(status: MessageStatus) -> MessageFooter { MessageFooter( @@ -76,6 +87,7 @@ public struct MessageFooter: Sendable, Hashable { hopCount: hopCount, formattedPath: formattedPath, regionToShow: regionToShow, + regionMatchNames: regionMatchNames, sendTimeToShow: sendTimeToShow, sendTimeWasCorrected: sendTimeWasCorrected, showStatusRow: showStatusRow, diff --git a/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift b/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift index f65e22267..9477b3b17 100644 --- a/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift +++ b/MC1Services/Sources/MC1Services/Models/RxLogEntry.swift @@ -46,11 +46,13 @@ final class RxLogEntry { /// 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. + /// Confident single flood-region name, or nil when unresolved or ambiguous. + /// Local-only. Read via `RegionScopeSemantics.coalesce` with matches. var regionScope: String? + /// Sorted multi-match set for this packet. Local-only. + var regionScopeMatches: [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`. @@ -82,6 +84,7 @@ final class RxLogEntry { toContactName: String? = nil, senderTimestamp: Int? = nil, regionScope: String? = nil, + regionScopeMatches: [String] = [], payloadTypeBits: Int = 0 ) { self.id = id @@ -105,6 +108,7 @@ final class RxLogEntry { self.toContactName = toContactName self.senderTimestamp = senderTimestamp self.regionScope = regionScope + self.regionScopeMatches = regionScopeMatches self.payloadTypeBits = payloadTypeBits } } @@ -140,11 +144,15 @@ public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { /// Mutable to allow updating during re-decryption of older entries. public var senderTimestamp: UInt32? - /// Resolved flood region the sender transmitted under. Local-only. + /// Confident single flood-region name, or nil when unresolved or ambiguous. + /// Local-only. Read via `RegionScopeSemantics.coalesce` with matches. public let regionScope: String? + /// Sorted multi-match set. Local-only. + public let regionScopeMatches: [String] + /// Raw 4-bit payload-type nibble from the wire header (matches firmware - /// HMAC input). Persisted alongside `regionScope` for back-fill replay. + /// HMAC input). Persisted alongside region fields for back-fill replay. public let payloadTypeBits: UInt8 /// Transient - set by UI layer after decryption @@ -175,6 +183,7 @@ public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { toContactName = model.toContactName senderTimestamp = model.senderTimestamp.flatMap { UInt32(exactly: $0) } regionScope = model.regionScope + regionScopeMatches = model.regionScopeMatches payloadTypeBits = UInt8(model.payloadTypeBits & 0x0F) decodedText = model.decodedText } @@ -192,6 +201,7 @@ public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { toContactName: String? = nil, senderTimestamp: UInt32? = nil, regionScope: String? = nil, + regionScopeMatches: [String] = [], decodedText: String? = nil ) { self.id = id @@ -215,6 +225,7 @@ public struct RxLogEntryDTO: Sendable, Identifiable, Equatable, Hashable { self.toContactName = toContactName self.senderTimestamp = senderTimestamp self.regionScope = regionScope + self.regionScopeMatches = regionScopeMatches payloadTypeBits = parsed.payloadTypeBits self.decodedText = decodedText } diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift index 94dbc3b36..8c1c50be4 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/RxLogPersisting.swift @@ -1,5 +1,18 @@ import Foundation +/// Retention bounds for the RX log radio partition. +/// +/// Shared by prune defaults and region reprocess so the fetch window stays +/// aligned with how many rows can exist before pruning. +public enum RxLogRetention { + /// Rows kept after a prune pass. + public static let keepCount = 1000 + + /// Extra rows allowed before prune runs. Peak retained count is + /// `keepCount + pruneThreshold`. + public static let pruneThreshold = 100 +} + /// Store operations for RX log entries: persistence, lookup, and batch enrichment. public protocol RxLogPersisting: Actor { // MARK: - RxLogEntry Lookup @@ -36,15 +49,22 @@ public protocol RxLogPersisting: Actor { /// 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 transport-coded RX log entries for region reprocess. + /// + /// Bound by prune retention (`limit`, typically + /// `RxLogRetention.keepCount + RxLogRetention.pruneThreshold`). Scans the + /// radio partition (indexed `radioID` + `receivedAt`); `transportCode` itself + /// is unindexed. Includes rows that already have a region label so unique, + /// multi-match, and clear rewrites all work. + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) 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 dual region fields on RX log entries by id + func batchUpdateRxLogRegion( + updates: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] + ) async throws /// Batch update RX log entries after successful decryption. /// Note: decodedText is transient and not persisted. @@ -52,19 +72,23 @@ public protocol RxLogPersisting: Actor { _ updates: [(id: UUID, channelIndex: UInt8?, channelName: String?, senderTimestamp: UInt32?)] ) async throws - /// Batch update `regionScope` on incoming channel `Message` rows - /// correlated by `(channelIndex, senderTimestamp)` + /// Batch update dual region fields on incoming channel `Message` rows + /// correlated by `(channelIndex, senderTimestamp)`. + /// - Returns: IDs of messages that were written (for open-chat invalidation). + @discardableResult func batchUpdateChannelMessageRegion( radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) async throws -> [UUID] - /// Batch update `regionScope` on incoming DM `Message` rows - /// correlated by `(senderPrefixByte, senderTimestamp)` + /// Batch update dual region fields on incoming DM `Message` rows + /// correlated by `(senderPrefixByte, senderTimestamp)`. + /// - Returns: IDs of messages that were written (for open-chat invalidation). + @discardableResult func batchUpdateDMMessageRegion( radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) async throws -> [UUID] } // MARK: - Default Parameter Values @@ -75,8 +99,12 @@ extension RxLogPersisting { try await fetchRxLogEntries(radioID: radioID, limit: 500) } - /// Prune RX log entries with the default retention cap of 1000 plus a 100-entry threshold + /// Prune RX log entries with the default retention cap plus threshold func pruneRxLogEntries(radioID: UUID) async throws { - try await pruneRxLogEntries(radioID: radioID, keepCount: 1000, pruneThreshold: 100) + try await pruneRxLogEntries( + radioID: radioID, + keepCount: RxLogRetention.keepCount, + pruneThreshold: RxLogRetention.pruneThreshold + ) } } diff --git a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift index 083766f6f..267ef3857 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift @@ -208,10 +208,20 @@ public enum MessageFragmentBuilder { envInputs: EnvInputs ) -> MessageFooter { let showHop = envInputs.showIncomingHopCount && message.isFloodRouted && !message.isOutgoing - let region: String? = if envInputs.showIncomingRegion, message.isFloodRouted { - message.regionScope + // Region chip: flood routes only, when the setting is on. Coalesce dual + // fields; bake the slash join here so the scroll path does not re-filter. + let regionToShow: String? + let regionMatchNames: [String] + if envInputs.showIncomingRegion, message.isFloodRouted { + let resolved = RegionScopeSemantics.coalesce( + scope: message.regionScope, + matches: message.regionScopeMatches + ) + regionToShow = RegionScopeSemantics.chipLabel(from: resolved) + regionMatchNames = RegionScopeSemantics.matchNames(from: resolved) } else { - nil + regionToShow = nil + regionMatchNames = [] } // Send time shows inside every bubble — incoming and outgoing, DM and // channel. It is the sole time surface now that the centered cluster @@ -225,7 +235,8 @@ public enum MessageFragmentBuilder { showHop: showHop, hopCount: message.hopCount, formattedPath: inputs.formattedPath, - regionToShow: region, + regionToShow: regionToShow, + regionMatchNames: regionMatchNames, sendTimeToShow: sendTimeToShow, sendTimeWasCorrected: message.timestampCorrected, showStatusRow: message.isOutgoing, diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift index 7ab41dcd4..ac78ad8f1 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Diagnostics.swift @@ -118,6 +118,7 @@ public extension PersistenceStore { toContactName: dto.toContactName, senderTimestamp: dto.senderTimestamp.map { Int($0) }, regionScope: dto.regionScope, + regionScopeMatches: dto.regionScopeMatches, payloadTypeBits: Int(dto.payloadTypeBits) ) modelContext.insert(entry) @@ -152,8 +153,8 @@ public extension PersistenceStore { /// retention bounded to `keepCount + pruneThreshold` entries between prune passes. func pruneRxLogEntries( radioID: UUID, - keepCount: Int = 1000, - pruneThreshold: Int = 100 + keepCount: Int = RxLogRetention.keepCount, + pruneThreshold: Int = RxLogRetention.pruneThreshold ) throws { let count = try cachedRxLogEntryCount(radioID: radioID) guard count > keepCount + pruneThreshold else { return } @@ -319,48 +320,59 @@ public extension PersistenceStore { 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] { + /// Fetch transport-coded RX log entries for region reprocess. + /// Bound by prune retention; includes rows that already have a label so + /// unique, multi-match, and clear rewrites all work. + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) throws -> [RxLogEntryDTO] { let targetRadioID = radioID - let descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { $0.radioID == targetRadioID && - $0.transportCode != nil && - $0.regionScope == nil + $0.transportCode != nil }, - sortBy: [SortDescriptor(\.receivedAt, order: .forward)] + sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] ) + descriptor.fetchLimit = limit let entries = try modelContext.fetch(descriptor) return entries.map { RxLogEntryDTO(from: $0) } } - /// Batch update `regionScope` on RX log entries by id. + /// Batch update dual region fields on RX log entries by id. func batchUpdateRxLogRegion( - updates: [(id: UUID, regionScope: String?)] + updates: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] ) throws { - for update in updates { - let targetID = update.id + guard !updates.isEmpty else { return } + let byID = Dictionary(uniqueKeysWithValues: updates.map { ($0.id, $0) }) + let targetIDs = Array(byID.keys) + // Chunk for SQLite variable limits on large reprocess writes. + let chunkSize = 200 + for start in stride(from: 0, to: targetIDs.count, by: chunkSize) { + let chunk = Array(targetIDs[start..( - predicate: #Predicate { $0.id == targetID } + predicate: #Predicate { chunk.contains($0.id) } ) - guard let entry = try modelContext.fetch(descriptor).first else { continue } - entry.regionScope = update.regionScope + for entry in try modelContext.fetch(descriptor) { + guard let update = byID[entry.id] else { continue } + entry.regionScope = update.regionScope + entry.regionScopeMatches = update.regionScopeMatches + } } try modelContext.save() } - /// Batch update `regionScope` on incoming **channel** `Message` rows + /// Batch update dual region fields 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`. + @discardableResult func batchUpdateChannelMessageRegion( radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) throws { + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) throws -> [UUID] { let targetRadioID = radioID let incoming = MessageDirection.incoming.rawValue + var touchedIDs: [UUID] = [] for update in updates { let targetIndex: UInt8? = update.channelIndex let targetSenderTimestamp: UInt32? = update.senderTimestamp @@ -376,24 +388,29 @@ public extension PersistenceStore { ) for message in try modelContext.fetch(descriptor) { message.regionScope = update.regionScope + message.regionScopeMatches = update.regionScopeMatches + touchedIDs.append(message.id) } } try modelContext.save() + return touchedIDs } - /// Batch update `regionScope` on incoming **DM** `Message` rows. DMs + /// Batch update dual region fields 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`. + @discardableResult func batchUpdateDMMessageRegion( radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) throws { + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) throws -> [UUID] { let targetRadioID = radioID let nilChannel: UInt8? = nil let incoming = MessageDirection.incoming.rawValue + var touchedIDs: [UUID] = [] for update in updates { let targetSenderTimestamp: UInt32? = update.senderTimestamp let targetWireTimestamp: UInt32 = update.senderTimestamp @@ -410,9 +427,12 @@ public extension PersistenceStore { let candidates = try modelContext.fetch(descriptor) for message in candidates where message.senderKeyPrefix?.first == prefixByte { message.regionScope = update.regionScope + message.regionScopeMatches = update.regionScopeMatches + touchedIDs.append(message.id) } } try modelContext.save() + return touchedIDs } // MARK: - Debug Log Entries diff --git a/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift b/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift index 3f4c760d7..7fae44bab 100644 --- a/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift +++ b/MC1Services/Sources/MC1Services/Services/RxLogService+RegionResolution.swift @@ -6,11 +6,22 @@ private let logger = PersistentLogger(subsystem: "com.mc1", category: "RxLogServ extension RxLogService { private static let missLogThrottleSeconds: TimeInterval = 60 + private static let ambiguousLogThrottleSeconds: TimeInterval = 60 /// Offset of the unencrypted sender prefix byte in a DM `packetPayload`, /// matching `findRxLogEntryBySenderPrefix`'s correlation key. private static let dmSenderPrefixByteOffset = 1 + /// Bound reprocess scan to the full retention window so rows that exist + /// between prune passes are not skipped. `transportCode` is unindexed; + /// fetch uses indexed `radioID` + `receivedAt`. + static let regionReprocessFetchLimit = + RxLogRetention.keepCount + RxLogRetention.pruneThreshold + + /// Yield every N entries so live `process` can interleave during multi-match + /// HMAC work (O(R) per entry). + private static let reprocessYieldInterval = 32 + /// Build a `[(name, scopeKey)]` array from the supplied region names. /// Skips names that `TransportCodeRegionResolver.deriveScopeKey` rejects /// (`$`-prefixed and empty/whitespace names). @@ -23,97 +34,139 @@ extension RxLogService { } } - /// 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, + /// Resolve `transport_codes[0]` into dual storage fields. + /// Live cost is O(R) HMACs per transport-coded packet; cache rebuilds only + /// when known regions change. + func resolveRegion(for parsed: ParsedRxLogData) -> (regionScope: String?, regionScopeMatches: [String]) { + resolveRegionStorage( + transportCode: parsed.transportCode, payloadTypeBits: parsed.payloadTypeBits, payload: parsed.packetPayload ) - if match == nil { - logRegionMissThrottled() - } - 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`. + /// Update known regions, rebuild the scope-key cache, and reprocess. + /// Runs even when the new list is empty so sticky labels can clear. public func updateKnownRegions(_ regions: [String]) async { guard knownRegions != regions else { return } knownRegions = regions scopeKeyCache = Self.buildScopeKeyCache(from: regions) - if !regions.isEmpty { - await reprocessNoRegionEntries() - } + await reprocessRegionEntries() + } + + /// Replace the scope-key cache and reprocess. Lets tests inject two names + /// that share one key without needing a real 16-bit code collision. + func replaceScopeKeyCacheAndReprocess( + _ cache: [(name: String, key: Data)] + ) async { + knownRegions = cache.map(\.name) + scopeKeyCache = cache + await reprocessRegionEntries() } - /// 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)`). + /// Re-resolve retained transport-coded entries against the current cache and + /// write dual region fields on `RxLogEntry` and correlated `Message` rows. /// - /// 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 { + /// Concurrent callers set dirty and park on a continuation the owner resumes + /// when the drain finishes. Overlapping callers converge on the final cache; + /// the number of passes is bounded by the number of overlapping callers that + /// arrive across pass boundaries. + func reprocessRegionEntries() async { + guard !Task.isCancelled else { return } + + regionReprocessDirty = true if isReprocessingRegions { - regionReprocessDirty = true + await withCheckedContinuation { (continuation: CheckedContinuation) in + regionReprocessWaiters.append(continuation) + } + guard !Task.isCancelled else { return } + // Owner finished between our dirty set and its last while-check. + // Re-enter so the mark is not lost. + if regionReprocessDirty { + await reprocessRegionEntries() + } return } + isReprocessingRegions = true - defer { isReprocessingRegions = false } + defer { + isReprocessingRegions = false + let waiters = regionReprocessWaiters + regionReprocessWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } - guard let radioID else { return } + guard let radioID else { + regionReprocessDirty = false + return + } - repeat { + while regionReprocessDirty { + guard !Task.isCancelled else { return } regionReprocessDirty = false await runReprocessPass(radioID: radioID) - } while regionReprocessDirty + } } private func runReprocessPass(radioID: UUID) async { do { - let entries = try await dataStore.fetchEntriesWithMissingRegion( - radioID: radioID + let entries = try await dataStore.fetchEntriesWithTransportCode( + radioID: radioID, + limit: Self.regionReprocessFetchLimit ) guard !entries.isEmpty else { return } - logger.info("Re-processing \(entries.count) entries for region back-fill") + logger.info("Re-processing \(entries.count) transport-coded entries for region resolution") + + // Snapshot so a mid-pass known-regions change is applied on the dirty re-run. + let cacheSnapshot = scopeKeyCache - var rxUpdates: [(id: UUID, regionScope: String?)] = [] - var channelMessageUpdates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] - var dmMessageUpdates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] = [] + var rxUpdates: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] = [] + var channelMessageUpdates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] = [] + var dmMessageUpdates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] = [] - for entry in entries { + for (index, entry) in entries.enumerated() { guard !Task.isCancelled else { break } - guard let resolved = resolveRegionScope(for: entry) else { continue } - rxUpdates.append((id: entry.id, regionScope: resolved)) + if index > 0, index.isMultiple(of: Self.reprocessYieldInterval) { + await Task.yield() + } + + let resolved = resolveRegionStorage( + transportCode: entry.transportCode, + payloadTypeBits: entry.payloadTypeBits, + payload: entry.packetPayload, + cache: cacheSnapshot, + logMisses: false + ) + + let scopeChanged = entry.regionScope != resolved.regionScope + let matchesChanged = entry.regionScopeMatches != resolved.regionScopeMatches + guard scopeChanged || matchesChanged else { continue } + + rxUpdates.append(( + id: entry.id, + regionScope: resolved.regionScope, + regionScopeMatches: resolved.regionScopeMatches + )) guard let senderTimestamp = entry.senderTimestamp else { continue } if let channelIndex = entry.channelIndex { channelMessageUpdates.append(( channelIndex: channelIndex, senderTimestamp: senderTimestamp, - regionScope: resolved + regionScope: resolved.regionScope, + regionScopeMatches: resolved.regionScopeMatches )) } else if entry.packetPayload.count >= Self.dmSenderPrefixByteOffset + 1 { let prefixByte = entry.packetPayload[Self.dmSenderPrefixByteOffset] dmMessageUpdates.append(( senderPrefixByte: prefixByte, senderTimestamp: senderTimestamp, - regionScope: resolved + regionScope: resolved.regionScope, + regionScopeMatches: resolved.regionScopeMatches )) } } @@ -121,16 +174,32 @@ extension RxLogService { if !rxUpdates.isEmpty { try await dataStore.batchUpdateRxLogRegion(updates: rxUpdates) } + + var touchedMessageIDs = Set() if !channelMessageUpdates.isEmpty { - try await dataStore.batchUpdateChannelMessageRegion(radioID: radioID, updates: channelMessageUpdates) + let ids = try await dataStore.batchUpdateChannelMessageRegion( + radioID: radioID, + updates: channelMessageUpdates + ) + touchedMessageIDs.formUnion(ids) } if !dmMessageUpdates.isEmpty { - try await dataStore.batchUpdateDMMessageRegion(radioID: radioID, updates: dmMessageUpdates) + let ids = try await dataStore.batchUpdateDMMessageRegion( + radioID: radioID, + updates: dmMessageUpdates + ) + touchedMessageIDs.formUnion(ids) + } + + if !touchedMessageIDs.isEmpty { + regionUpdateBroadcaster.yield(Array(touchedMessageIDs)) } let messageCount = channelMessageUpdates.count + dmMessageUpdates.count if !rxUpdates.isEmpty || messageCount > 0 { - logger.info("Back-filled \(rxUpdates.count) RxLog entries, \(messageCount) messages") + logger.info( + "Region reprocess wrote \(rxUpdates.count) RxLog entries, \(messageCount) message correlations (\(touchedMessageIDs.count) message IDs)" + ) } } catch { logger.error("Failed to re-process region entries: \(error.localizedDescription)") @@ -150,26 +219,50 @@ extension RxLogService { } } - /// 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 logRegionAmbiguousThrottled(names: [String]) { + let now = Date() + if let last = lastRegionAmbiguousLogTime, now.timeIntervalSince(last) < Self.ambiguousLogThrottleSeconds { + return + } + lastRegionAmbiguousLogTime = now + // Public region names only; never scopeKey bytes, payload, or raw codes. + logger.debug( + "Region resolution ambiguous: \(names.count) matches \(names.joined(separator: ", "))" ) } - 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 } + private func resolveRegionStorage( + transportCode: Data?, + payloadTypeBits: UInt8, + payload: Data, + cache: [(name: String, key: Data)]? = nil, + logMisses: Bool = true + ) -> (regionScope: String?, regionScopeMatches: [String]) { + guard let transportCode, transportCode.count >= 2 else { + return (nil, []) + } + let activeCache = cache ?? scopeKeyCache + // Empty cache skips HMACs but still projects none so writers can clear labels. + guard !activeCache.isEmpty else { + return (nil, []) + } let code0 = transportCode.readUInt16LE(at: 0) - return TransportCodeRegionResolver.findMatchingRegion( - scopeKeys: scopeKeyCache, + let match = TransportCodeRegionResolver.matchRegions( + scopeKeys: activeCache, expectedTransportCode0: code0, payloadTypeBits: payloadTypeBits, payload: payload ) + if logMisses { + switch match { + case .none: + logRegionMissThrottled() + case let .ambiguous(names): + logRegionAmbiguousThrottled(names: names) + case .unique: + break + } + } + return RegionScopeSemantics.storageFields(from: match) } } diff --git a/MC1Services/Sources/MC1Services/Services/RxLogService.swift b/MC1Services/Sources/MC1Services/Services/RxLogService.swift index 8effd76b3..e84abb24c 100644 --- a/MC1Services/Sources/MC1Services/Services/RxLogService.swift +++ b/MC1Services/Sources/MC1Services/Services/RxLogService.swift @@ -23,9 +23,18 @@ public actor RxLogService { /// via `entryStream()`; finished by `ServiceContainer.tearDown()`. private nonisolated let entryBroadcaster = EventBroadcaster() + /// Multicast broadcaster for Message IDs whose region fields reprocess + /// rewrote. Open chats subscribe via `regionUpdateEvents()` to re-bake + /// region footers. + nonisolated let regionUpdateBroadcaster = EventBroadcaster<[UUID]>() + /// Event monitoring private var eventMonitorTask: Task? + /// Connect-path region reprocess. Cancelled with event monitoring so it + /// cannot write or yield after teardown finishes the broadcaster. + private var regionReprocessTask: Task? + /// Heard repeats processing. /// Injected by `ServiceContainer` at construction. private let heardRepeatsService: HeardRepeatsService? @@ -35,14 +44,22 @@ public actor RxLogService { private var isReprocessingDMs = false var isReprocessingRegions = false + /// Waiters parked while a region reprocess drain owns the actor. + /// The owner resumes them in its defer. + var regionReprocessWaiters: [CheckedContinuation] = [] + // Region resolution state var knownRegions: [String] = [] var scopeKeyCache: [(name: String, key: Data)] = [] var lastRegionMissLogTime: Date? + var lastRegionAmbiguousLogTime: 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. + /// Overlapping callers converge on the final cache; the number of passes + /// is bounded by the number of overlapping callers that arrive across + /// pass boundaries. var regionReprocessDirty = false public init(session: any MeshCoreSessionProtocol, dataStore: any PersistenceStoreProtocol, heardRepeatsService: HeardRepeatsService?) { @@ -58,6 +75,7 @@ public actor RxLogService { deinit { eventMonitorTask?.cancel() + regionReprocessTask?.cancel() } // MARK: - Event Monitoring @@ -66,16 +84,22 @@ public actor RxLogService { public func startEventMonitoring(radioID: UUID) { self.radioID = radioID eventMonitorTask?.cancel() + regionReprocessTask?.cancel() + regionReprocessTask = nil 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 + // Build known-regions cache (and channel/contact secrets). await loadSecretsFromDatabase(radioID: radioID) + // Subscribe before reprocess. EventDispatcher only buffers for existing + // subscribers; awaiting full reprocess here would drop live RX. let events = await session.events(filter: .rxLogData) + // Sibling reprocess so the for-await loop drains while store work runs. + await self.launchRegionReprocessTask() + for await event in events { guard !Task.isCancelled else { break } if case let .rxLogData(parsed) = event { @@ -85,6 +109,12 @@ public actor RxLogService { } } + /// Store a cancellable handle for connect-path region reprocess. + private func launchRegionReprocessTask() { + regionReprocessTask?.cancel() + regionReprocessTask = Task { await self.reprocessRegionEntries() } + } + /// Load channel secrets and contact public keys from database to enable decryption before sync completes. private func loadSecretsFromDatabase(radioID: UUID) async { do { @@ -122,6 +152,8 @@ public actor RxLogService { public func stopEventMonitoring() { eventMonitorTask?.cancel() eventMonitorTask = nil + regionReprocessTask?.cancel() + regionReprocessTask = nil } /// Returns a fresh multicast stream of newly persisted entries. @@ -131,11 +163,18 @@ public actor RxLogService { entryBroadcaster.subscribe() } - /// Ends every `entryStream()` subscriber's for-await loop. Called by - /// `ServiceContainer.tearDown()` so consumer tasks release their service - /// references. + /// Multicast stream of Message IDs whose region fields reprocess changed. + /// Open chats reload baked region footers from these IDs. + public nonisolated func regionUpdateEvents() -> AsyncStream<[UUID]> { + regionUpdateBroadcaster.subscribe() + } + + /// Ends every `entryStream()` / `regionUpdateEvents()` subscriber's + /// for-await loop. Called by `ServiceContainer.tearDown()` so consumer + /// tasks release their service references. nonisolated func finishEntryStream() { entryBroadcaster.finish() + regionUpdateBroadcaster.finish() } /// Rebuilds the channel cache from a fresh channel list. @@ -418,7 +457,7 @@ public actor RxLogService { } } - let regionScope = resolveRegionScope(for: parsed) + let regionFields = resolveRegion(for: parsed) // Create DTO let dto = RxLogEntryDTO( @@ -429,7 +468,8 @@ public actor RxLogService { decryptStatus: decryptStatus, fromContactName: fromContactName, senderTimestamp: senderTimestamp, - regionScope: regionScope, + regionScope: regionFields.regionScope, + regionScopeMatches: regionFields.regionScopeMatches, decodedText: decodedText ) diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift index a788755d6..849e31e1d 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+HandlerHelpers.swift @@ -10,6 +10,7 @@ extension SyncCoordinator { let packetHash: String? let routeType: RouteType? let regionScope: String? + let regionScopeMatches: [String] } /// Looks up path data from an RxLogEntry to correlate with an incoming message. @@ -38,7 +39,14 @@ extension SyncCoordinator { } 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) + return RxLogLookupResult( + pathNodes: pathNodes, + pathLength: pathLength, + packetHash: rxEntry.packetHash, + routeType: rxEntry.routeType, + regionScope: rxEntry.regionScope, + regionScopeMatches: rxEntry.regionScopeMatches + ) } // Fallback for DMs: if timestamp-based lookup failed (e.g., RxLog decryption @@ -53,7 +61,14 @@ extension SyncCoordinator { 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: rxEntry.pathNodes, + pathLength: rxEntry.pathLength, + packetHash: rxEntry.packetHash, + routeType: rxEntry.routeType, + regionScope: rxEntry.regionScope, + regionScopeMatches: rxEntry.regionScopeMatches + ) } logger.debug("No RxLogEntry found for direct message (primary + fallback), senderTimestamp: \(senderTimestamp)") } else if let channelIndex { @@ -69,7 +84,14 @@ extension SyncCoordinator { } } - return RxLogLookupResult(pathNodes: nil, pathLength: defaultPathLength, packetHash: nil, routeType: nil, regionScope: nil) + return RxLogLookupResult( + pathNodes: nil, + pathLength: defaultPathLength, + packetHash: nil, + routeType: nil, + regionScope: nil, + regionScopeMatches: [] + ) } /// Increments unread counts and posts a notification for a direct message. diff --git a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift index 599ea0297..e8adec0a1 100644 --- a/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift +++ b/MC1Services/Sources/MC1Services/Sync/SyncCoordinator+MessageHandlers.swift @@ -234,7 +234,8 @@ extension SyncCoordinator { timestampCorrected: timestampCorrected, senderTimestamp: timestampCorrected ? timestamp : nil, routeType: rxResult.routeType, - regionScope: rxResult.regionScope + regionScope: rxResult.regionScope, + regionScopeMatches: rxResult.regionScopeMatches ) // Check for duplicate before saving diff --git a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift index 68f701a22..d23ae5634 100644 --- a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift @@ -3304,6 +3304,70 @@ struct BackupIntegrationTests { #expect(nilModel.regionScope == nil) } + // MARK: - Message.regionScopeMatches round-trip + + @Test + func `Message.regionScopeMatches 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 = nil + msg.regionScopeMatches = ["de-by", "de-hh"] + msg.deduplicationKey = "region-scope-matches-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 == nil) + #expect(restored.first?.regionScopeMatches == ["de-by", "de-hh"]) + } + + @Test + func `MessageDTO Codable: regionScopeMatches set decodes round-trip`() throws { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Test") + dto.regionScopeMatches = ["de-by", "de-hh"] + + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded.regionScopeMatches == ["de-by", "de-hh"]) + } + + @Test + func `Legacy MessageDTO envelope without regionScopeMatches decodes as empty`() 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: "regionScopeMatches") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) + #expect(decoded.regionScopeMatches == []) + } + + @Test + func `Message(dto:) forwards regionScopeMatches verbatim through DTO to model`() { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Forward") + dto.regionScopeMatches = ["de-by", "de-hh"] + let model = Message(dto: dto) + #expect(model.regionScopeMatches == ["de-by", "de-hh"]) + } + // MARK: - MessageDTO.sortDate round-trip @Test diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index c6be5b6c7..7c91d6ba6 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -1308,9 +1308,9 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { // 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 private(set) var updatedRxLogRegions: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] = [] + public private(set) var updatedChannelMessageRegions: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] = [] + public private(set) var updatedDMMessageRegions: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] = [] public func saveRxLogEntry(_ dto: RxLogEntryDTO) async throws { mockRxLogEntries.append(dto) @@ -1336,15 +1336,22 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { 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 fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { + Array( + mockRxLogEntries + .filter { $0.radioID == radioID && $0.transportCode != nil } + .sorted { $0.receivedAt > $1.receivedAt } + .prefix(limit) + ) } 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 { + public func batchUpdateRxLogRegion( + updates: [(id: UUID, regionScope: String?, regionScopeMatches: [String])] + ) async throws { updatedRxLogRegions.append(contentsOf: updates) } @@ -1359,18 +1366,22 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { } } + @discardableResult public func batchUpdateChannelMessageRegion( radioID: UUID, - updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws { + updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) async throws -> [UUID] { updatedChannelMessageRegions.append(contentsOf: updates) + return [] } + @discardableResult public func batchUpdateDMMessageRegion( radioID: UUID, - updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?)] - ) async throws { + updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])] + ) async throws -> [UUID] { updatedDMMessageRegions.append(contentsOf: updates) + return [] } // MARK: - Saved Trace Paths diff --git a/MC1Services/Tests/MC1ServicesTests/Models/RegionScopeSemanticsTests.swift b/MC1Services/Tests/MC1ServicesTests/Models/RegionScopeSemanticsTests.swift new file mode 100644 index 000000000..66ac8f4f3 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Models/RegionScopeSemanticsTests.swift @@ -0,0 +1,81 @@ +import Foundation +@testable import MC1Services +import MeshCore +import Testing + +@Suite("RegionScopeSemantics") +struct RegionScopeSemanticsTests { + @Test + func `storageFields maps none unique and ambiguous`() { + #expect(RegionScopeSemantics.storageFields(from: .none).regionScope == nil) + #expect(RegionScopeSemantics.storageFields(from: .none).regionScopeMatches == []) + + let unique = RegionScopeSemantics.storageFields(from: .unique("Germany")) + #expect(unique.regionScope == "Germany") + #expect(unique.regionScopeMatches == ["Germany"]) + + let ambiguous = RegionScopeSemantics.storageFields(from: .ambiguous(["de-hh", "de-by"])) + #expect(ambiguous.regionScope == nil) + #expect(ambiguous.regionScopeMatches == ["de-by", "de-hh"]) + } + + @Test + func `coalesce prefers multi-match over sticky scope`() { + let result = RegionScopeSemantics.coalesce( + scope: "Germany", + matches: ["de-hh", "de-by"] + ) + #expect(result == .ambiguous(["de-by", "de-hh"])) + } + + @Test + func `coalesce unique from single match`() { + #expect( + RegionScopeSemantics.coalesce(scope: nil, matches: ["USA"]) == .unique("USA") + ) + } + + @Test + func `coalesce legacy unique from scope only`() { + #expect( + RegionScopeSemantics.coalesce(scope: "Bavaria", matches: []) == .unique("Bavaria") + ) + } + + @Test + func `coalesce none when both empty`() { + #expect(RegionScopeSemantics.coalesce(scope: nil, matches: []) == .none) + #expect(RegionScopeSemantics.coalesce(scope: " ", matches: ["", " "]) == .none) + } + + @Test + func `chipLabel joins ambiguous with slash separator`() { + #expect(RegionScopeSemantics.chipLabel(from: .none) == nil) + #expect(RegionScopeSemantics.chipLabel(from: .unique("Germany")) == "Germany") + #expect( + RegionScopeSemantics.chipLabel(from: .ambiguous(["de-by", "de-hh"])) + == "de-by / de-hh" + ) + } + + @Test + func `matchRegions multi-match never stores first-match as regionScope`() throws { + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0x01, 0x02, 0x03, 0x04]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, + payloadTypeBits: 5, + payload: payload + ) + let match = TransportCodeRegionResolver.matchRegions( + scopeKeys: [("First", scopeKey), ("Second", scopeKey)], + expectedTransportCode0: code, + payloadTypeBits: 5, + payload: payload + ) + let fields = RegionScopeSemantics.storageFields(from: match) + #expect(fields.regionScope == nil) + #expect(fields.regionScopeMatches.count == 2) + #expect(Set(fields.regionScopeMatches) == Set(["First", "Second"])) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift index 8ad3c6452..6b6b6dd2a 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreTests.swift @@ -2106,10 +2106,12 @@ struct PersistenceStoreTests { radioID: UUID, senderTimestamp: UInt32? = nil, regionScope: String? = nil, + regionScopeMatches: [String] = [], payloadTypeBits: UInt8 = 5, transportCode: Data? = nil, channelIndex: UInt8? = 1, - packetPayload: Data = Data([0xAB, 0xCD, 0xEF]) + packetPayload: Data = Data([0xAB, 0xCD, 0xEF]), + receivedAt: Date = Date() ) -> RxLogEntryDTO { // Create minimal ParsedRxLogData for the DTO let parsed = ParsedRxLogData( @@ -2128,12 +2130,14 @@ struct PersistenceStoreTests { return RxLogEntryDTO( radioID: radioID, + receivedAt: receivedAt, from: parsed, channelIndex: channelIndex, channelName: "TestChannel", decryptStatus: .success, senderTimestamp: senderTimestamp, regionScope: regionScope, + regionScopeMatches: regionScopeMatches, decodedText: "Hello mesh!" ) } @@ -2316,12 +2320,78 @@ struct PersistenceStoreTests { let dto = createTestRxLogEntryDTO( radioID: device.id, senderTimestamp: 1_703_000_000, - regionScope: "Germany" + regionScope: "Germany", + regionScopeMatches: ["Germany"] ) try await store.saveRxLogEntry(dto) let entries = try await store.fetchRxLogEntries(radioID: device.id) #expect(entries.first?.regionScope == "Germany") + #expect(entries.first?.regionScopeMatches == ["Germany"]) + } + + @Test + func `batchUpdateRxLogRegion writes dual region fields`() 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_050, + regionScope: "Germany", + regionScopeMatches: ["Germany"], + transportCode: Data([0x12, 0x34]) + ) + try await store.saveRxLogEntry(dto) + let id = try #require(try await store.fetchRxLogEntries(radioID: device.id).first?.id) + + try await store.batchUpdateRxLogRegion(updates: [( + id: id, + regionScope: nil, + regionScopeMatches: ["de-by", "de-hh"] + )]) + + let updated = try #require(try await store.fetchRxLogEntries(radioID: device.id).first) + #expect(updated.regionScope == nil) + #expect(updated.regionScopeMatches == ["de-by", "de-hh"]) + } + + @Test + func `fetchEntriesWithTransportCode includes labeled rows and honors limit`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let base = Date(timeIntervalSince1970: 1_700_000_000) + // Five transport-coded rows with distinct receivedAt; one nil-code row to exclude. + for index in 0..<5 { + try await store.saveRxLogEntry(createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: UInt32(index), + regionScope: index == 0 ? "Germany" : nil, + regionScopeMatches: index == 0 ? ["Germany"] : [], + transportCode: Data([UInt8(index), 0x02]), + receivedAt: base.addingTimeInterval(TimeInterval(index)) + )) + } + try await store.saveRxLogEntry(createTestRxLogEntryDTO( + radioID: device.id, + senderTimestamp: 99, + transportCode: nil, + receivedAt: base.addingTimeInterval(100) + )) + + let limit = 2 + let entries = try await store.fetchEntriesWithTransportCode(radioID: device.id, limit: limit) + #expect(entries.count == limit) + #expect(entries.allSatisfy { $0.transportCode != nil }) + // Newest-first by receivedAt: index 4 then 3 when sort is .reverse and fetchLimit applies. + #expect(entries.map(\.receivedAt) == [ + base.addingTimeInterval(4), + base.addingTimeInterval(3) + ]) + #expect(entries.map(\.senderTimestamp) == [4, 3]) } @Test @@ -2360,11 +2430,12 @@ struct PersistenceStoreTests { try await store.batchUpdateChannelMessageRegion( radioID: device.id, - updates: [(channelIndex: 0, senderTimestamp: wireTimestamp, regionScope: "Germany")] + updates: [(channelIndex: 0, senderTimestamp: wireTimestamp, regionScope: "Germany", regionScopeMatches: ["Germany"])] ) let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 0) #expect(saved.first?.regionScope == "Germany") + #expect(saved.first?.regionScopeMatches == ["Germany"]) } @Test @@ -2386,11 +2457,43 @@ struct PersistenceStoreTests { try await store.batchUpdateChannelMessageRegion( radioID: device.id, - updates: [(channelIndex: 1, senderTimestamp: originalWire, regionScope: "USA")] + updates: [(channelIndex: 1, senderTimestamp: originalWire, regionScope: "USA", regionScopeMatches: ["USA"])] ) let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 1) #expect(saved.first?.regionScope == "USA") + #expect(saved.first?.regionScopeMatches == ["USA"]) + } + + @Test + func `batchUpdateChannelMessageRegion writes multi-match regionScopeMatches with nil scope`() async throws { + let store = try await createTestStore() + let device = createTestDevice() + try await store.saveDevice(device) + + let wireTimestamp: UInt32 = 1_703_666_666 + let dto = MessageDTO.testChannelMessage( + radioID: device.id, + channelIndex: 3, + timestamp: wireTimestamp, + direction: .incoming, + status: .delivered + ) + try await store.saveMessage(dto) + + try await store.batchUpdateChannelMessageRegion( + radioID: device.id, + updates: [( + channelIndex: 3, + senderTimestamp: wireTimestamp, + regionScope: nil, + regionScopeMatches: ["First", "Second"] + )] + ) + + let saved = try #require(try await store.fetchMessages(radioID: device.id, channelIndex: 3).first) + #expect(saved.regionScope == nil) + #expect(Set(saved.regionScopeMatches) == Set(["First", "Second"])) } @Test @@ -2411,7 +2514,7 @@ struct PersistenceStoreTests { try await store.batchUpdateChannelMessageRegion( radioID: device.id, - updates: [(channelIndex: 2, senderTimestamp: wireTimestamp, regionScope: "France")] + updates: [(channelIndex: 2, senderTimestamp: wireTimestamp, regionScope: "France", regionScopeMatches: ["France"])] ) let saved = try await store.fetchMessages(radioID: device.id, channelIndex: 2) @@ -2439,11 +2542,12 @@ struct PersistenceStoreTests { try await store.batchUpdateDMMessageRegion( radioID: device.id, - updates: [(senderPrefixByte: 0xAB, senderTimestamp: wireTimestamp, regionScope: "Germany")] + updates: [(senderPrefixByte: 0xAB, senderTimestamp: wireTimestamp, regionScope: "Germany", regionScopeMatches: ["Germany"])] ) let saved = try await store.fetchMessages(contactID: contactID) #expect(saved.first?.regionScope == "Germany") + #expect(saved.first?.regionScopeMatches == ["Germany"]) } @Test @@ -2481,7 +2585,7 @@ struct PersistenceStoreTests { try await store.batchUpdateDMMessageRegion( radioID: device.id, - updates: [(senderPrefixByte: 0xAA, senderTimestamp: wireTimestamp, regionScope: "Germany")] + updates: [(senderPrefixByte: 0xAA, senderTimestamp: wireTimestamp, regionScope: "Germany", regionScopeMatches: ["Germany"])] ) let aliceSaved = try await store.fetchMessages(contactID: aliceContact) diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceRegionReprocessTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceRegionReprocessTests.swift new file mode 100644 index 000000000..a581b7443 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Services/RxLogServiceRegionReprocessTests.swift @@ -0,0 +1,485 @@ +import Foundation +@testable import MC1Services +@testable import MeshCore +import Testing + +@Suite("RxLogService region multi-match reprocess", .serialized) +struct RxLogServiceRegionReprocessTests { + @Test + func `replaceScopeKeyCache rewrites sticky first-match to ambiguous multi-match`() 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) + + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["First"] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0x10, 0x20, 0x30, 0x40, 0x50]) + let payloadTypeBits: UInt8 = 5 + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, + payloadTypeBits: payloadTypeBits, + payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + let senderTimestamp: UInt32 = 1_704_000_000 + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: payloadTypeBits, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: 0, + channelName: "Public", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: "First", + regionScopeMatches: ["First"] + ) + try await dataStore.saveRxLogEntry(entry) + + // Correlated channel message must follow the RxLog sticky → ambiguous rewrite. + let message = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + timestamp: senderTimestamp, + direction: .incoming, + status: .delivered + ) + try await dataStore.saveMessage(message) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let stream = service.regionUpdateEvents() + let collector = IDCollector() + let task = Task { + for await ids in stream { + await collector.record(ids) + if await collector.count > 0 { break } + } + } + + // Two display names sharing one scope key — the multi-match fixture. + await service.replaceScopeKeyCacheAndReprocess([ + (name: "First", key: scopeKey), + (name: "Second", key: scopeKey) + ]) + + let updated = try #require( + try await dataStore.fetchRxLogEntries(radioID: radioID).first { $0.id == entry.id } + ) + #expect(updated.regionScope == nil, "ambiguous must clear sticky regionScope") + #expect(Set(updated.regionScopeMatches) == Set(["First", "Second"])) + + try await waitUntil("region update event should fire for multi-match message rewrite") { + await collector.count > 0 + } + task.cancel() + + let receivedIDs = await collector.ids + #expect(receivedIDs.contains(message.id)) + + let savedMessages = try await dataStore.fetchMessages(radioID: radioID, channelIndex: 0) + let savedMessage = try #require(savedMessages.first { $0.id == message.id }) + #expect(savedMessage.regionScope == nil, "ambiguous must clear message regionScope") + #expect(Set(savedMessage.regionScopeMatches) == Set(["First", "Second"])) + } + + @Test + func `empty known regions clears sticky labels`() 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) + + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["Germany"] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0xAA, 0xBB, 0xCC]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, payloadTypeBits: 5, payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + decryptStatus: .success, + senderTimestamp: 1_704_000_100, + regionScope: "Germany", + regionScopeMatches: ["Germany"] + ) + try await dataStore.saveRxLogEntry(entry) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + // Wait for loadSecrets so knownRegions is ["Germany"] before clearing. + // Calling updateKnownRegions([]) while still default-empty is a no-op. + try await waitUntil("known regions loaded from device") { + await service.knownRegions == ["Germany"] + } + + await service.updateKnownRegions([]) + + let updated = try #require( + try await dataStore.fetchRxLogEntries(radioID: radioID).first { $0.id == entry.id } + ) + #expect(updated.regionScope == nil) + #expect(updated.regionScopeMatches == []) + } + + @Test + func `live resolve persists unique name and matches array together`() 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) + + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["Germany"] }) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + // Wait for loadSecrets so the live path resolves against the device regions. + try await waitUntil("known regions loaded from device") { + await service.knownRegions == ["Germany"] + } + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0x01, 0x02, 0x03, 0x04]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, payloadTypeBits: 5, payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + await service.process(parsed) + + let entries = try await dataStore.fetchRxLogEntries(radioID: radioID) + let live = try #require(entries.first { $0.transportCode == transportCode }) + #expect(live.regionScope == "Germany") + #expect(live.regionScopeMatches == ["Germany"]) + } + + @Test + func `regionUpdateEvents yields message IDs after channel message reprocess`() 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) + + // Non-matching seed so loadSecrets can finish without resolving the Germany code. + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["Placeholder"] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, payloadTypeBits: 5, payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + let senderTimestamp: UInt32 = 1_704_111_000 + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: 0, + channelName: "Public", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: nil, + regionScopeMatches: [] + ) + try await dataStore.saveRxLogEntry(entry) + + let message = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + timestamp: senderTimestamp, + direction: .incoming, + status: .delivered + ) + try await dataStore.saveMessage(message) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let stream = service.regionUpdateEvents() + let collector = IDCollector() + let task = Task { + for await ids in stream { + await collector.record(ids) + if await collector.count > 0 { break } + } + } + + // Wait for loadSecrets so its empty/placeholder cache cannot clobber Germany. + try await waitUntil("known regions loaded from device") { + await service.knownRegions == ["Placeholder"] + } + + await service.updateKnownRegions(["Germany"]) + + try await waitUntil("region update event should fire for correlated message") { + await collector.count > 0 + } + task.cancel() + + let receivedIDs = await collector.ids + #expect(receivedIDs.contains(message.id)) + let saved = try await dataStore.fetchMessages(radioID: radioID, channelIndex: 0) + #expect(saved.first?.regionScope == "Germany") + #expect(saved.first?.regionScopeMatches == ["Germany"]) + } + + @Test + func `regionUpdateEvents yields message IDs after DM message reprocess`() 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) + + // Non-matching seed so loadSecrets can finish without resolving the Germany code. + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = ["Placeholder"] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + // Byte at offset 1 is the unencrypted sender prefix used for DM correlation. + let senderPrefixByte: UInt8 = 0xAB + let packetPayload = Data([0x00, senderPrefixByte, 0xCC, 0xDD, 0xEE]) + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, payloadTypeBits: 5, payload: packetPayload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + let senderTimestamp: UInt32 = 1_704_222_000 + let contactID = UUID() + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: packetPayload, + routeType: .tcFlood, + payloadType: .textMessage, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: packetPayload + ) + // nil channelIndex marks a DM entry; correlation uses packetPayload[1]. + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: nil, + channelName: nil, + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: nil, + regionScopeMatches: [] + ) + try await dataStore.saveRxLogEntry(entry) + + let message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + timestamp: senderTimestamp, + direction: .incoming, + status: .delivered, + senderKeyPrefix: Data([senderPrefixByte, 0x11, 0x22, 0x33]) + ) + try await dataStore.saveMessage(message) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + let stream = service.regionUpdateEvents() + let collector = IDCollector() + let task = Task { + for await ids in stream { + await collector.record(ids) + if await collector.count > 0 { break } + } + } + + // Wait for loadSecrets so its empty/placeholder cache cannot clobber Germany. + try await waitUntil("known regions loaded from device") { + await service.knownRegions == ["Placeholder"] + } + + await service.updateKnownRegions(["Germany"]) + + try await waitUntil("region update event should fire for correlated DM") { + await collector.count > 0 + } + task.cancel() + + let receivedIDs = await collector.ids + #expect(receivedIDs.contains(message.id)) + + let saved = try #require(try await dataStore.fetchMessage(id: message.id)) + #expect(saved.regionScope == "Germany") + #expect(saved.regionScopeMatches == ["Germany"]) + } + + @Test + func `reprocess overwrites intermediate resolution with final cache`() 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) + + try await dataStore.saveDevice(.testDevice(id: radioID).copy { $0.knownRegions = [] }) + + let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) + let payload = Data([0xCA, 0xFE, 0xBA, 0xBE]) + let payloadTypeBits: UInt8 = 5 + let code = TransportCodeRegionResolver.calcTransportCode( + scopeKey: scopeKey, + payloadTypeBits: payloadTypeBits, + payload: payload + ) + var transportCode = Data(count: 2) + transportCode[0] = UInt8(code & 0xFF) + transportCode[1] = UInt8((code >> 8) & 0xFF) + let senderTimestamp: UInt32 = 1_704_333_000 + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: payload, + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: payloadTypeBits, + transportCode: transportCode, + pathLength: 0, + pathNodes: [], + packetPayload: payload + ) + let entry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: 0, + channelName: "Public", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: "Stale", + regionScopeMatches: ["Stale"] + ) + try await dataStore.saveRxLogEntry(entry) + + let message = MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: 0, + timestamp: senderTimestamp, + direction: .incoming, + status: .delivered + ) + try await dataStore.saveMessage(message) + + await service.startEventMonitoring(radioID: radioID) + defer { Task { await service.stopEventMonitoring() } } + + // Intermediate unique cache must land before the final multi-match overwrite. + await service.replaceScopeKeyCacheAndReprocess([(name: "Germany", key: scopeKey)]) + + let mid = try #require( + try await dataStore.fetchRxLogEntries(radioID: radioID).first { $0.id == entry.id } + ) + #expect(mid.regionScope == "Germany") + #expect(mid.regionScopeMatches == ["Germany"]) + + await service.replaceScopeKeyCacheAndReprocess([ + (name: "First", key: scopeKey), + (name: "Second", key: scopeKey) + ]) + + let finalEntry = try #require( + try await dataStore.fetchRxLogEntries(radioID: radioID).first { $0.id == entry.id } + ) + #expect(finalEntry.regionScope == nil) + #expect(Set(finalEntry.regionScopeMatches) == Set(["First", "Second"])) + + let finalMessage = try #require(try await dataStore.fetchMessage(id: message.id)) + #expect(finalMessage.regionScope == nil) + #expect(Set(finalMessage.regionScopeMatches) == Set(["First", "Second"])) + } + + private actor IDCollector { + private(set) var ids: [UUID] = [] + var count: Int { + ids.count + } + + func record(_ newIDs: [UUID]) { + ids.append(contentsOf: newIDs) + } + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift index 643516176..395d7b6d6 100644 --- a/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SyncCoordinatorMessageHandlerTests.swift @@ -311,6 +311,68 @@ struct SyncCoordinatorMessageHandlerTests { ) #expect((updated.lastHeardTimestamp ?? 0) == 0) } + + // MARK: - Live receive region propagation + + @Test + func `live channel receive copies regionScope and regionScopeMatches from RxLog onto Message`() async throws { + let radioID = UUID() + let dataStore = try await createTestDataStore(radioID: radioID) + let channelIndex: UInt8 = 0 + // Fixed integer epoch so ChannelMessage.senderTimestamp and RxLogEntry + // correlate without fractional-second truncation. + let senderTimestamp: UInt32 = 1_704_000_500 + let expectedScope: String? = "Germany" + let expectedMatches = ["Germany"] + + let parsed = ParsedRxLogData( + snr: 5, + rssi: -80, + rawPayload: Data([0x10, 0x20, 0x30]), + routeType: .flood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: nil, + pathLength: 0, + pathNodes: [], + packetPayload: Data([0xAA, 0xBB, 0xCC]) + ) + let rxEntry = RxLogEntryDTO( + radioID: radioID, + from: parsed, + channelIndex: channelIndex, + channelName: "Public", + decryptStatus: .success, + senderTimestamp: senderTimestamp, + regionScope: expectedScope, + regionScopeMatches: expectedMatches + ) + try await dataStore.saveRxLogEntry(rxEntry) + + let mockPolling = MockMessagePollingService() + let (_, services) = try await createTestServices() + let dependencies = services.syncDependencies + .with(dataStore: dataStore, messagePollingService: mockPolling) + + let coordinator = SyncCoordinator() + await coordinator.wireMessageHandlers(dependencies: dependencies, radioID: radioID) + + let channelMessage = ChannelMessage( + channelIndex: channelIndex, + pathLength: 0, + textType: 0, + senderTimestamp: Date(timeIntervalSince1970: TimeInterval(senderTimestamp)), + text: "NodeAlpha: region scope test", + snr: nil + ) + await mockPolling.capturedChannelMessageHandler?(channelMessage, nil, .live) + + let saved = try await dataStore.fetchMessages(radioID: radioID, channelIndex: channelIndex) + let message = try #require(saved.first) + #expect(message.regionScope == expectedScope) + #expect(Set(message.regionScopeMatches) == Set(expectedMatches)) + } } // MARK: - SyncDependencies test helpers diff --git a/MC1Tests/Services/InlineImagePrefetcherTests.swift b/MC1Tests/Services/InlineImagePrefetcherTests.swift index f30d051a8..b0c51bc74 100644 --- a/MC1Tests/Services/InlineImagePrefetcherTests.swift +++ b/MC1Tests/Services/InlineImagePrefetcherTests.swift @@ -585,7 +585,7 @@ private actor StubDataStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -593,10 +593,17 @@ private actor StubDataStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index 4fc599a3a..c4480a4ec 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -556,7 +556,7 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -564,10 +564,17 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } /// Channel Message Deletion func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} diff --git a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift index 19c5eae45..a8fdeabb6 100644 --- a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift +++ b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift @@ -422,7 +422,7 @@ actor MockPersistenceStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -430,10 +430,17 @@ actor MockPersistenceStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } // MARK: - Channel Message Deletion (stubs) diff --git a/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift b/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift index 44d462538..19bf6f4aa 100644 --- a/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift @@ -741,7 +741,7 @@ private actor AdmissionStubDataStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -749,10 +749,17 @@ private actor AdmissionStubDataStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} diff --git a/MC1Tests/Views/Chats/Components/MessageBubblePredicateTests.swift b/MC1Tests/Views/Chats/Components/MessageBubblePredicateTests.swift index 6e85eeb14..fc05c2f5d 100644 --- a/MC1Tests/Views/Chats/Components/MessageBubblePredicateTests.swift +++ b/MC1Tests/Views/Chats/Components/MessageBubblePredicateTests.swift @@ -172,6 +172,68 @@ struct MessageBubblePredicateTests { #expect(bubble.accessibilityMessageLabel.contains(regionFragment) == true) } + @Test + func `footer bakes multi-match chip label and ambiguous flag`() { + let message = makeMessage( + routeType: .flood, + regionScope: "Germany", // sticky first-match must lose to multi-match + regionScopeMatches: ["de-hh", "de-by"] + ) + let bundle = MessageBubbleTestData.messageItem( + message: message, + showIncomingRegion: true + ) + let footer = bundle.item.footer + + #expect(footer.regionToShow == "de-by / de-hh") + #expect(footer.regionIsAmbiguous) + #expect(footer.regionMatchNames == ["de-by", "de-hh"]) + } + + @Test + func `footer bakes legacy scope-only as unique without ambiguous flag`() { + let message = makeMessage(routeType: .flood, regionScope: "Germany", regionScopeMatches: []) + let bundle = MessageBubbleTestData.messageItem( + message: message, + showIncomingRegion: true + ) + let footer = bundle.item.footer + + #expect(footer.regionToShow == "Germany") + #expect(!footer.regionIsAmbiguous) + #expect(footer.regionMatchNames == ["Germany"]) + } + + @Test + func `accessibilityMessageLabel lists all candidates when ambiguous`() { + // Ambiguous region footer (nil sticky scope, multi-match candidates) must surface + // every candidate name in the screen-reader label, not only the chip text. + let message = makeMessage( + routeType: .flood, + regionScope: nil, + regionScopeMatches: ["de-hh", "de-by"] + ) + let bundle = MessageBubbleTestData.messageItem( + message: message, + showIncomingRegion: true + ) + let footer = bundle.item.footer + let bubble = UnifiedMessageBubble( + message: message, + contactName: "Alice", + configuration: .directMessage, + item: bundle.item, + layout: FragmentLayout(content: bundle.item.content), + imageResolver: bundle.imageResolver + ) + + #expect(footer.regionIsAmbiguous) + #expect(footer.regionMatchNames.contains("de-by")) + #expect(footer.regionMatchNames.contains("de-hh")) + #expect(bubble.accessibilityMessageLabel.contains("de-by")) + #expect(bubble.accessibilityMessageLabel.contains("de-hh")) + } + // MARK: - Helpers private func makeMessage( @@ -180,7 +242,8 @@ struct MessageBubblePredicateTests { pathNodes: Data? = Data([0xA3, 0x7F]), direction: MessageDirection = .incoming, routeType: RouteType? = nil, - regionScope: String? = nil + regionScope: String? = nil, + regionScopeMatches: [String] = [] ) -> MessageDTO { MessageDTO( id: UUID(), @@ -206,7 +269,8 @@ struct MessageBubblePredicateTests { retryAttempt: 0, maxRetryAttempts: 0, routeType: routeType, - regionScope: regionScope + regionScope: regionScope, + regionScopeMatches: regionScopeMatches ) } } diff --git a/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift b/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift index a77cf7e93..35d1d8496 100644 --- a/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift +++ b/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift @@ -801,7 +801,7 @@ actor ParkingContactStore: PersistenceStoreProtocol { func clearRxLogEntries(radioID: UUID) async throws {} func pruneRxLogEntries(radioID: UUID, keepCount: Int, pruneThreshold: Int) async throws {} - func fetchEntriesWithMissingRegion(radioID: UUID) async throws -> [RxLogEntryDTO] { + func fetchEntriesWithTransportCode(radioID: UUID, limit: Int) async throws -> [RxLogEntryDTO] { [] } @@ -809,10 +809,18 @@ actor ParkingContactStore: PersistenceStoreProtocol { [] } - func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?)]) async throws {} + func batchUpdateRxLogRegion(updates: [(id: UUID, regionScope: String?, regionScopeMatches: [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 {} + @discardableResult + func batchUpdateChannelMessageRegion(radioID: UUID, updates: [(channelIndex: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + + @discardableResult + func batchUpdateDMMessageRegion(radioID: UUID, updates: [(senderPrefixByte: UInt8, senderTimestamp: UInt32, regionScope: String?, regionScopeMatches: [String])]) async throws -> [UUID] { + [] + } + func deleteMessagesForChannel(radioID: UUID, channelIndex: UInt8) async throws {} // swiftlint:disable:next function_parameter_count func saveNodeStatusSnapshot( diff --git a/MeshCore/Sources/MeshCore/Protocol/TransportCodeRegionResolver.swift b/MeshCore/Sources/MeshCore/Protocol/TransportCodeRegionResolver.swift index 3bf4fcf31..7cc45aa42 100644 --- a/MeshCore/Sources/MeshCore/Protocol/TransportCodeRegionResolver.swift +++ b/MeshCore/Sources/MeshCore/Protocol/TransportCodeRegionResolver.swift @@ -76,30 +76,54 @@ public enum TransportCodeRegionResolver { return rawCode } - /// Find the matching region name for a packet, given a precomputed - /// `[(regionName, scopeKey)]` array. First match wins (mirrors firmware - /// iteration order). + /// Match a packet against every precomputed `[(regionName, scopeKey)]` entry. /// - /// Empty-array input short-circuits to nil. Callers (e.g. `RxLogService`) - /// own the cache and must rebuild it whenever the known-regions list - /// changes. - public static func findMatchingRegion( + /// Collects every name whose `calcTransportCode` equals + /// `expectedTransportCode0` (no first-hit early exit). Blank names are + /// dropped. Names are sorted with `localizedStandardCompare` so ambiguous + /// sets are order-stable. Empty input returns `.none`. Live cost is O(R) + /// HMACs per transport-coded packet; callers own and rebuild the cache. + public static func matchRegions( scopeKeys: [(name: String, key: Data)], expectedTransportCode0: UInt16, payloadTypeBits: UInt8, payload: Data - ) -> String? { - guard !scopeKeys.isEmpty else { return nil } + ) -> RegionMatchResult { + guard !scopeKeys.isEmpty else { return .none } + + var matchedNames: [String] = [] for (name, key) in scopeKeys { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } let code = calcTransportCode( scopeKey: key, payloadTypeBits: payloadTypeBits, payload: payload ) if code == expectedTransportCode0 { - return name + matchedNames.append(trimmed) } } - return nil + + let uniqueSorted = Array(Set(matchedNames)).sorted { + $0.localizedStandardCompare($1) == .orderedAscending + } + + switch uniqueSorted.count { + case 0: + return .none + case 1: + return .unique(uniqueSorted[0]) + default: + return .ambiguous(uniqueSorted) + } } } + +/// Result of matching a packet's `transport_codes[0]` against known public +/// region scope keys. Ambiguous sets always carry at least two sorted names. +public enum RegionMatchResult: Equatable, Sendable { + case none + case unique(String) + case ambiguous([String]) +} diff --git a/MeshCore/Tests/MeshCoreTests/TransportCodeRegionResolverTests.swift b/MeshCore/Tests/MeshCoreTests/TransportCodeRegionResolverTests.swift index 5ceca8233..75b33ce15 100644 --- a/MeshCore/Tests/MeshCoreTests/TransportCodeRegionResolverTests.swift +++ b/MeshCore/Tests/MeshCoreTests/TransportCodeRegionResolverTests.swift @@ -129,7 +129,7 @@ struct TransportCodeRegionResolverTests { // MARK: - Region matching @Test - func `Round-trip: compute code then resolve back to region name`() throws { + func `Round-trip: compute code then resolve back to unique region name`() throws { let regionName = "Germany" let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: regionName)) let expectedCode = TransportCodeRegionResolver.calcTransportCode( @@ -139,28 +139,28 @@ struct TransportCodeRegionResolverTests { ) let scopeKeys: [(name: String, key: Data)] = [(regionName, scopeKey)] - let match = TransportCodeRegionResolver.findMatchingRegion( + let match = TransportCodeRegionResolver.matchRegions( scopeKeys: scopeKeys, expectedTransportCode0: expectedCode, payloadTypeBits: groupTextPayloadBits, payload: samplePayload ) - #expect(match == regionName) + #expect(match == .unique(regionName)) } @Test - func `Empty scopeKeys returns nil`() { - let match = TransportCodeRegionResolver.findMatchingRegion( + func `Empty scopeKeys returns none`() { + let match = TransportCodeRegionResolver.matchRegions( scopeKeys: [], expectedTransportCode0: 0x1234, payloadTypeBits: groupTextPayloadBits, payload: samplePayload ) - #expect(match == nil) + #expect(match == .none) } @Test - func `No matching region returns nil`() throws { + func `No matching region returns none`() throws { let germany = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) let usa = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "USA")) let actualCode = TransportCodeRegionResolver.calcTransportCode( @@ -174,17 +174,17 @@ struct TransportCodeRegionResolverTests { ("Germany", germany), ("USA", usa) ] - let match = TransportCodeRegionResolver.findMatchingRegion( + let match = TransportCodeRegionResolver.matchRegions( scopeKeys: scopeKeys, expectedTransportCode0: wrongCode, payloadTypeBits: groupTextPayloadBits, payload: samplePayload ) - #expect(match == nil) + #expect(match == .none) } @Test - func `First match wins when iterating scopeKeys`() throws { + func `Ambiguous match returns all names sorted independent of input order`() throws { let scopeKey = try #require(TransportCodeRegionResolver.deriveScopeKey(regionName: "Germany")) let expectedCode = TransportCodeRegionResolver.calcTransportCode( scopeKey: scopeKey, @@ -192,17 +192,45 @@ struct TransportCodeRegionResolverTests { payload: samplePayload ) - // Same key listed twice under different names — first one wins. - let scopeKeys: [(name: String, key: Data)] = [ - ("FirstName", scopeKey), - ("SecondName", scopeKey) + // Same key under two names — both verify the transport code; never first-match only. + let forward: [(name: String, key: Data)] = [ + ("de-by", scopeKey), + ("de-hh", scopeKey) ] - let match = TransportCodeRegionResolver.findMatchingRegion( - scopeKeys: scopeKeys, + let reverse: [(name: String, key: Data)] = [ + ("de-hh", scopeKey), + ("de-by", scopeKey) + ] + + let forwardMatch = TransportCodeRegionResolver.matchRegions( + scopeKeys: forward, expectedTransportCode0: expectedCode, payloadTypeBits: groupTextPayloadBits, payload: samplePayload ) - #expect(match == "FirstName") + let reverseMatch = TransportCodeRegionResolver.matchRegions( + scopeKeys: reverse, + expectedTransportCode0: expectedCode, + payloadTypeBits: groupTextPayloadBits, + payload: samplePayload + ) + + #expect(forwardMatch == .ambiguous(["de-by", "de-hh"])) + #expect(reverseMatch == .ambiguous(["de-by", "de-hh"])) + #expect(forwardMatch == reverseMatch) + } + + @Test + func `Dollar-prefixed private regions never match via empty scope key`() { + // deriveScopeKey returns nil for $ names, so callers never put them in the + // cache; matchRegions with an empty list is the production path. + #expect(TransportCodeRegionResolver.deriveScopeKey(regionName: "$secret") == nil) + let match = TransportCodeRegionResolver.matchRegions( + scopeKeys: [], + expectedTransportCode0: 0x1234, + payloadTypeBits: groupTextPayloadBits, + payload: samplePayload + ) + #expect(match == .none) } } From d311e93617980a08c383c83be98bedccaf29f334 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:53:26 -0700 Subject: [PATCH 08/47] fix(chats): attach repeats after a node rename Match heard repeats on channel, timestamp, and text. The connect-time node name is not a join key. --- .../MC1Services/ServiceContainer.swift | 17 +------ .../Services/HeardRepeatsService.swift | 20 +++----- .../Services/HeardRepeatsServiceTests.swift | 51 ++++++++++++++----- 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/MC1Services/Sources/MC1Services/ServiceContainer.swift b/MC1Services/Sources/MC1Services/ServiceContainer.swift index 6cccebf5b..af1f1f318 100644 --- a/MC1Services/Sources/MC1Services/ServiceContainer.swift +++ b/MC1Services/Sources/MC1Services/ServiceContainer.swift @@ -1,6 +1,5 @@ import Foundation import MeshCore -import OSLog import SwiftData /// Dependency injection container for MC1Services. @@ -346,21 +345,7 @@ public final class ServiceContainer { 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)") - } + await heardRepeatsService.configure(radioID: radioID) // Start event monitoring for services that need it if enableAdvertisementMonitoring { diff --git a/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift b/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift index e2e28a885..5f53d9ae0 100644 --- a/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift +++ b/MC1Services/Sources/MC1Services/Services/HeardRepeatsService.swift @@ -11,9 +11,6 @@ public actor 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() @@ -36,13 +33,11 @@ public actor HeardRepeatsService { eventBroadcaster.finish() } - /// Configure the service with device context. + /// Configure the service with the connected radio. /// Must be called once before processing any RX log entries. - /// Thread-safe due to actor isolation. - public func configure(radioID: UUID, localNodeName: String) { + public func configure(radioID: UUID) { self.radioID = radioID - self.localNodeName = localNodeName - logger.info("Configured with radioID: \(radioID), nodeName: \(localNodeName)") + logger.info("Configured with radioID: \(radioID)") } /// Checks if a repeat has already been recorded for this RX log entry. @@ -72,17 +67,14 @@ public actor HeardRepeatsService { 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 { + // Body after the first colon is the stored outgoing text. Sender name + // is not a join key: findSentChannelMessage already scopes to this radio. + guard let (_, 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)") diff --git a/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift index eed840a1e..3a34962d6 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/HeardRepeatsServiceTests.swift @@ -126,7 +126,7 @@ struct HeardRepeatsServiceTests { text: "north repeater check", timestamp: sendTimestamp )) - await service.configure(radioID: radioID, localNodeName: Self.testNodeName) + await service.configure(radioID: radioID) let events = service.events() let echo = makeEcho( @@ -162,7 +162,7 @@ struct HeardRepeatsServiceTests { text: "hello", timestamp: sendTimestamp )) - await service.configure(radioID: radioID, localNodeName: Self.testNodeName) + await service.configure(radioID: radioID) let echo = makeEcho( radioID: radioID, @@ -180,7 +180,7 @@ struct HeardRepeatsServiceTests { } @Test - func `no match for unknown timestamp or foreign sender`() async throws { + func `no match for unknown timestamp`() async throws { let (store, service) = try makeStoreAndService() let radioID = UUID() let channelIndex: UInt8 = 1 @@ -191,7 +191,7 @@ struct HeardRepeatsServiceTests { text: "hello", timestamp: sendTimestamp )) - await service.configure(radioID: radioID, localNodeName: Self.testNodeName) + await service.configure(radioID: radioID) let wrongTimestamp = makeEcho( radioID: radioID, @@ -200,15 +200,6 @@ struct HeardRepeatsServiceTests { 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 @@ -236,4 +227,38 @@ struct HeardRepeatsServiceTests { ) #expect(matchA?.id == aID) } + + /// Air prefix is not a join key. Body, timestamp, and channel still match. + @Test + func `new-node rename then three TEST Hello echoes attach`() 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) + + var lastCount: Int? + for _ in 0..<3 { + let echo = makeEcho( + radioID: radioID, + channelIndex: channelIndex, + senderTimestamp: sendTimestamp, + body: "Hello", + senderName: "TEST" + ) + lastCount = await service.processForRepeats(echo) + } + + #expect(lastCount == 3) + let repeats = try await store.fetchMessageRepeats(messageID: messageID) + #expect(repeats.count == 3) + } } From edc11e70e2b10565f30837656754934069f93967 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:03:31 -0700 Subject: [PATCH 09/47] fix(remote-nodes): rewrite clock sync to time Companion firmware restamps CLI packets to its own RTC. Stock clock sync then sets the node from that stamp, not the phone. Rewrite remote clock sync to time . Sync Time and Tools CLI then set the node from the host clock. --- .../RemoteNodes/NodeSettingsViewModel.swift | 4 +- .../Services/RemoteCLICommandRewriter.swift | 22 ++++++++++ .../Services/RemoteNodeService+CLI.swift | 2 + .../RemoteCLICommandRewriterTests.swift | 30 +++++++++++++ .../RemoteNodeCLICorrelationTests.swift | 28 ++++++++++++ ...NodeSettingsViewModelValidationTests.swift | 44 +++++++++++++++++++ 6 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 MC1Services/Sources/MC1Services/Services/RemoteCLICommandRewriter.swift create mode 100644 MC1Services/Tests/MC1ServicesTests/Services/RemoteCLICommandRewriterTests.swift diff --git a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift index 2e7826494..be0f6e0d7 100644 --- a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift @@ -546,7 +546,9 @@ final class NodeSettingsViewModel { errorMessage = nil do { - let response = try await sendAndWait("clock sync") + let response = try await sendAndWait( + RemoteCLICommandRewriter.rewrite(RemoteCLICommandRewriter.clockSyncCommand) + ) switch NodeSettingsResponseParser.classifyClockSyncResponse(response) { case .synced: successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced diff --git a/MC1Services/Sources/MC1Services/Services/RemoteCLICommandRewriter.swift b/MC1Services/Sources/MC1Services/Services/RemoteCLICommandRewriter.swift new file mode 100644 index 000000000..00f1cfc13 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/RemoteCLICommandRewriter.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Rewrites remote `clock sync` to `time `. Companion firmware +/// restamps CLI packets to its own RTC, so the packet timestamp is not the phone clock. +public enum RemoteCLICommandRewriter: Sendable { + public static let clockSyncCommand = "clock sync" + public static let timeCommandPrefix = "time " + + public static func rewrite(_ command: String, now: Date = Date()) -> String { + let normalized = command.split(whereSeparator: \.isWhitespace).joined(separator: " ").lowercased() + guard normalized == clockSyncCommand else { return command } + return timeCommandPrefix + String(epochSeconds32(now)) + } + + /// Saturates pre-1970 and post-2106 dates instead of trapping `UInt32(_:)`. + private static func epochSeconds32(_ date: Date) -> UInt32 { + let seconds = date.timeIntervalSince1970 + guard seconds.isFinite, seconds > 0 else { return 0 } + guard seconds < Double(UInt32.max) else { return UInt32.max } + return UInt32(seconds) + } +} diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift index f970b468f..fa7279811 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+CLI.swift @@ -67,6 +67,8 @@ extension RemoteNodeService { throw RemoteNodeError.permissionDenied } + let command = RemoteCLICommandRewriter.rewrite(command) + // Log CLI command (with password redaction) await auditLogger.logCLICommand(publicKey: remoteSession.publicKey, command: command) diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteCLICommandRewriterTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteCLICommandRewriterTests.swift new file mode 100644 index 000000000..a1e4dce38 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteCLICommandRewriterTests.swift @@ -0,0 +1,30 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("RemoteCLICommandRewriter") +struct RemoteCLICommandRewriterTests { + private static let now = Date(timeIntervalSince1970: 1_786_722_487) + private static let expected = "time 1786722487" + + @Test + func `clock sync becomes time with the host epoch`() { + #expect(RemoteCLICommandRewriter.rewrite("clock sync", now: Self.now) == Self.expected) + } + + @Test(arguments: ["CLOCK SYNC", " clock sync ", "Clock Sync"]) + func `clock sync match is case and whitespace insensitive`(command: String) { + #expect(RemoteCLICommandRewriter.rewrite(command, now: Self.now) == Self.expected) + } + + @Test(arguments: ["clock", "time 123", "clock sync extra", "sync_time", "st"]) + func `other commands are left unchanged`(command: String) { + #expect(RemoteCLICommandRewriter.rewrite(command, now: Self.now) == command) + } + + @Test + func `pre epoch dates saturate to zero`() { + let preEpoch = Date(timeIntervalSince1970: -1000) + #expect(RemoteCLICommandRewriter.rewrite("clock sync", now: preEpoch) == "time 0") + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeCLICorrelationTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeCLICorrelationTests.swift index 4c6eb35fa..b3ff4c64d 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeCLICorrelationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/RemoteNodeCLICorrelationTests.swift @@ -235,4 +235,32 @@ struct RemoteNodeCLICorrelationTests { await yieldReply(prefix + "US/CA^", to: harness.session) #expect(try await commandTask.value == "US/CA^") } + + @Test + func `clock sync is rewritten to time with the host epoch on the wire`() async throws { + let harness = try await makeHarness() + let before = UInt32(Date().timeIntervalSince1970) + + let commandTask = Task { + try await harness.service.sendRawCLICommand( + sessionID: harness.sessionID, + command: "clock sync" + ) + } + try await waitUntil("command was never sent") { + await harness.session.sendCommandInvocations.count == 1 + } + + let after = UInt32(Date().timeIntervalSince1970) + let sent = await harness.session.sendCommandInvocations.last?.command ?? "" + let split = try #require(CLIResponse.splitEchoedPrefix(sent)) + #expect(split.body.hasPrefix(RemoteCLICommandRewriter.timeCommandPrefix)) + let epochText = String(split.body.dropFirst(RemoteCLICommandRewriter.timeCommandPrefix.count)) + let epoch = try #require(UInt32(epochText)) + #expect(epoch >= before && epoch <= after) + + let prefix = try await sentWirePrefix(of: harness.session) + await yieldReply(prefix + "OK - clock set: 15:48 - 14/8/2026 UTC", to: harness.session) + #expect(try await commandTask.value == "OK - clock set: 15:48 - 14/8/2026 UTC") + } } diff --git a/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift b/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift index 686db4418..87443ea6f 100644 --- a/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift +++ b/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift @@ -268,3 +268,47 @@ struct NodeSettingsRadioApplyTests { #expect(recorder.commands == ["get radio"]) } } + +@Suite("NodeSettingsViewModel clock sync") +@MainActor +struct NodeSettingsClockSyncTests { + @MainActor + final class CommandRecorder { + private(set) var commands: [String] = [] + func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { + commands.append(command) + return "OK - clock set: 15:35 - 14/8/2026 UTC" + } + } + + private func makeConfiguredViewModel(recorder: CommandRecorder) -> NodeSettingsViewModel { + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Node", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ) + let viewModel = NodeSettingsViewModel() + viewModel.configure(session: session, sendCommand: recorder.send, sendRawCommand: recorder.send) + return viewModel + } + + @Test + func `syncTime sends the host epoch as time not bare clock sync`() async throws { + let recorder = CommandRecorder() + let viewModel = makeConfiguredViewModel(recorder: recorder) + let before = UInt32(Date().timeIntervalSince1970) + + await viewModel.syncTime() + + let after = UInt32(Date().timeIntervalSince1970) + #expect(recorder.commands.count == 1) + let command = try #require(recorder.commands.first) + #expect(command.hasPrefix("time ")) + let epochText = String(command.dropFirst(5)) + let epoch = try #require(UInt32(epochText)) + #expect(epoch >= before && epoch <= after) + } +} From a07e0ed49b052b84718c742c040d1a265a79d9f3 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:19:49 -0700 Subject: [PATCH 10/47] fix(remote-nodes): compare clocks in device info Telemetry warned from a login snapshot vs the radio clock. Device Time used a later CLI clock query. Those two samples did not match, so the banner stayed after the node looked right. Measure drift when Device Info loads clock. Show the warning next to Device Time. Drop the login snapshot and the Telemetry banner. --- .../RemoteNodes/NodeSettingsViewModel.swift | 20 ++++++++- .../Repeaters/RepeaterSettingsView.swift | 5 +-- .../Repeaters/RepeaterStatusContent.swift | 4 +- .../Repeaters/RepeaterStatusView.swift | 5 +-- .../RemoteNodes/Rooms/RoomSettingsView.swift | 5 +-- .../RemoteNodes/Rooms/RoomStatusContent.swift | 4 +- .../RemoteNodes/Rooms/RoomStatusView.swift | 5 +-- .../RemoteNodes/SharedNodeSettingsViews.swift | 19 ++++++++ .../RemoteNodes/SharedNodeStatusViews.swift | 20 --------- .../MC1Services/ServiceContainer.swift | 4 -- .../Services/NodeSettingsResponseParser.swift | 17 +++++++ .../Services/RemoteNodeService+Login.swift | 7 --- .../Services/RemoteNodeService.swift | 33 -------------- .../NodeSettingsResponseParserTests.swift | 32 ++++++++++++++ ...NodeSettingsViewModelValidationTests.swift | 44 ++++++++++++++++++- 15 files changed, 135 insertions(+), 89 deletions(-) diff --git a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift index be0f6e0d7..873c3e67b 100644 --- a/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/NodeSettingsViewModel.swift @@ -18,6 +18,10 @@ final class NodeSettingsViewModel { var firmwareVersion: String? private var deviceTimeUTC: String? + /// Positive means the node's clock is ahead of `now`. + var clockDrift: TimeInterval? + /// Reference clock used when measuring `clockDrift`. + var now: () -> Date = Date.init var isLoadingDeviceInfo = false var deviceInfoError = false var deviceInfoLoaded: Bool { @@ -39,6 +43,13 @@ final class NodeSettingsViewModel { return "\(timeString) - \(dateString)" } + func applyDeviceTime(_ raw: String) { + if let text = NodeSettingsResponseParser.clockResponseText(in: raw) { + deviceTimeUTC = text + clockDrift = NodeSettingsResponseParser.clockDrift(fromClockResponse: text, relativeTo: now()) + } + } + // MARK: - Identity var name: String? @@ -225,7 +236,7 @@ final class NodeSettingsViewModel { do { let response = try await sendAndWait("clock") if case let .deviceTime(time) = CLIResponse.parse(response, forQuery: "clock") { - deviceTimeUTC = time + applyDeviceTime(time) } } catch { if case RemoteNodeError.timeout = error { @@ -551,6 +562,11 @@ final class NodeSettingsViewModel { ) switch NodeSettingsResponseParser.classifyClockSyncResponse(response) { case .synced: + if NodeSettingsResponseParser.clockResponseText(in: response) != nil { + applyDeviceTime(response) + } else { + clockDrift = nil + } successMessage = L10n.RemoteNodes.RemoteNodes.Settings.timeSynced showSuccessAlert = true case .clockAhead: @@ -701,7 +717,7 @@ final class NodeSettingsViewModel { } registerLateRecovery(query: "clock") { [weak self] value in guard let self, case let .deviceTime(time) = value else { return } - deviceTimeUTC = time + applyDeviceTime(time) deviceInfoError = firmwareVersion == nil } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift index 3163d7233..ecb01d70e 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift @@ -21,7 +21,6 @@ struct RepeaterSettingsView: View { /// 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? - @State private var clockDrift: TimeInterval? var body: some View { // ZStack, not Group: a stable container keeps the navigation title hosted on one @@ -40,8 +39,7 @@ struct RepeaterSettingsView: View { discoveredNodes: discoveredNodes, userLocation: appState.bestAvailableLocation, connectedDeviceID: appState.connectedDevice?.radioID, - routePathContact: routeContact, - clockDrift: clockDrift + routePathContact: routeContact ) } } @@ -70,7 +68,6 @@ struct RepeaterSettingsView: View { contacts = await (try? dataStore.fetchContacts(radioID: radioID)) ?? [] discoveredNodes = await (try? dataStore.fetchDiscoveredNodes(radioID: radioID)) ?? [] } - clockDrift = await appState.services?.remoteNodeService.loginClockDrift(sessionID: session.id) await refreshRouteContact() } .onChange(of: appState.contactsVersion) { diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift index 476713217..422442adb 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusContent.swift @@ -20,12 +20,10 @@ struct RepeaterStatusContent: View { let connectedDeviceID: UUID? /// Contact whose login route is shown at the bottom; nil hides the route section. var routePathContact: ContactDTO? - /// Clock drift measured at login; nil when no login carried the node's clock. - var clockDrift: TimeInterval? var body: some View { List { - NodeStatusHeaderSection(session: session, clockDrift: clockDrift) + NodeStatusHeaderSection(session: session) StatusSection(viewModel: viewModel, session: session, connectionState: connectionState) NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { await viewModel.requestTelemetry(for: session) diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift index b121c89d6..968588927 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterStatusView.swift @@ -13,7 +13,6 @@ struct RepeaterStatusView: View { /// 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? - @State private var clockDrift: TimeInterval? var body: some View { NavigationStack { @@ -25,8 +24,7 @@ struct RepeaterStatusView: View { discoveredNodes: discoveredNodes, userLocation: appState.bestAvailableLocation, connectedDeviceID: appState.connectedDevice?.radioID, - routePathContact: routeContact, - clockDrift: clockDrift + routePathContact: routeContact ) .navigationTitle(L10n.RemoteNodes.RemoteNodes.Status.title) .navigationBarTitleDisplayMode(.inline) @@ -65,7 +63,6 @@ struct RepeaterStatusView: View { } } await refreshRouteContact() - clockDrift = await appState.services?.remoteNodeService.loginClockDrift(sessionID: session.id) } .onChange(of: appState.contactsVersion) { Task { await refreshRouteContact() } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift b/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift index 33ea176e4..3d353b166 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomSettingsView.swift @@ -15,7 +15,6 @@ struct RoomSettingsView: View { @State private var showRebootConfirmation = false @State private var showingLocationPicker = false @State private var telemetryConfigured = false - @State private var clockDrift: TimeInterval? var body: some View { // ZStack, not Group: a stable container keeps the navigation title hosted on one @@ -30,8 +29,7 @@ struct RoomSettingsView: View { viewModel: statusViewModel, session: session, connectionState: appState.connectionState, - connectedDeviceID: appState.connectedDevice?.radioID, - clockDrift: clockDrift + connectedDeviceID: appState.connectedDevice?.radioID ) } } @@ -53,7 +51,6 @@ struct RoomSettingsView: View { if let send = viewModel.makeNodeCLISendClosure(session: session) { cliViewModel.configure(sessionName: session.name, sendRawCommand: send) } - clockDrift = await appState.services?.remoteNodeService.loginClockDrift(sessionID: session.id) } .onChange(of: managementTab) { _, newTab in guard newTab == .telemetry, !telemetryConfigured else { return } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift index 6cb52d25a..bbe121bee 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusContent.swift @@ -13,12 +13,10 @@ struct RoomStatusContent: View { let session: RemoteNodeSessionDTO let connectionState: DeviceConnectionState let connectedDeviceID: UUID? - /// Clock drift measured at login; nil when no login carried the node's clock. - var clockDrift: TimeInterval? var body: some View { List { - NodeStatusHeaderSection(session: session, clockDrift: clockDrift) + NodeStatusHeaderSection(session: session) RoomStatusSection(viewModel: viewModel, session: session, connectionState: connectionState) NodeTelemetryDisclosureSection(helper: viewModel.helper, connectionState: connectionState) { await viewModel.requestTelemetry(for: session) diff --git a/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift b/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift index 903e4f516..d7fb522a2 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomStatusView.swift @@ -8,7 +8,6 @@ struct RoomStatusView: View { let session: RemoteNodeSessionDTO @State private var viewModel = RoomStatusViewModel() - @State private var clockDrift: TimeInterval? var body: some View { NavigationStack { @@ -16,8 +15,7 @@ struct RoomStatusView: View { viewModel: viewModel, session: session, connectionState: appState.connectionState, - connectedDeviceID: appState.connectedDevice?.radioID, - clockDrift: clockDrift + connectedDeviceID: appState.connectedDevice?.radioID ) .navigationTitle(L10n.RemoteNodes.RemoteNodes.RoomStatus.title) .navigationBarTitleDisplayMode(.inline) @@ -50,7 +48,6 @@ struct RoomStatusView: View { if let radioID = appState.connectedDevice?.radioID { await viewModel.helper.loadOCVSettings(publicKey: session.publicKey, radioID: radioID) } - clockDrift = await appState.services?.remoteNodeService.loginClockDrift(sessionID: session.id) } } .onDisappear { diff --git a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift index 409e32d0d..383f5be25 100644 --- a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift @@ -47,6 +47,9 @@ struct NodeSettingsHeaderSection: View { // MARK: - Device Info Section struct NodeDeviceInfoSection: View { + /// Clock drift below this magnitude is normal RTC scatter and not shown. + private static let clockDriftWarningThreshold: TimeInterval = 300 + @Bindable var settings: NodeSettingsViewModel var body: some View { @@ -62,8 +65,24 @@ struct NodeDeviceInfoSection: View { ) { LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.firmware, value: settings.firmwareVersion ?? NodeStatusViewModel.emDash) LabeledContent(L10n.RemoteNodes.RemoteNodes.Settings.deviceTime, value: settings.deviceTime ?? NodeStatusViewModel.emDash) + if let drift = settings.clockDrift, abs(drift) >= Self.clockDriftWarningThreshold { + Label(clockDriftWarning(drift), systemImage: "clock.badge.exclamationmark") + .font(.footnote) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } } } + + private func clockDriftWarning(_ drift: TimeInterval) -> String { + let magnitude = Duration.seconds(abs(drift)).formatted( + .units(allowed: [.days, .hours, .minutes, .seconds], width: .abbreviated, maximumUnitCount: 2) + ) + return drift > 0 + ? L10n.RemoteNodes.RemoteNodes.Status.clockAhead(magnitude) + : L10n.RemoteNodes.RemoteNodes.Status.clockBehind(magnitude) + } } // MARK: - Radio Settings Section diff --git a/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift b/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift index ebb3e9b5d..0be1197ec 100644 --- a/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeStatusViews.swift @@ -4,11 +4,7 @@ import SwiftUI // MARK: - Status Header struct NodeStatusHeaderSection: View { - /// Clock drift below this magnitude is normal RTC scatter and not shown. - private static let clockDriftWarningThreshold: TimeInterval = 300 - let session: RemoteNodeSessionDTO - var clockDrift: TimeInterval? var body: some View { Section { @@ -25,13 +21,6 @@ struct NodeStatusHeaderSection: View { .font(.subheadline) .foregroundStyle(.secondary) } - - if let drift = clockDrift, abs(drift) >= Self.clockDriftWarningThreshold { - Label(clockDriftWarning(drift), systemImage: "clock.badge.exclamationmark") - .font(.footnote) - .foregroundStyle(.orange) - .multilineTextAlignment(.center) - } } Spacer() } @@ -40,15 +29,6 @@ struct NodeStatusHeaderSection: View { } .listSectionSpacing(.compact) } - - private func clockDriftWarning(_ drift: TimeInterval) -> String { - let magnitude = Duration.seconds(abs(drift)).formatted( - .units(allowed: [.days, .hours, .minutes, .seconds], width: .abbreviated, maximumUnitCount: 2) - ) - return drift > 0 - ? L10n.RemoteNodes.RemoteNodes.Status.clockAhead(magnitude) - : L10n.RemoteNodes.RemoteNodes.Status.clockBehind(magnitude) - } } // MARK: - Common Status Rows diff --git a/MC1Services/Sources/MC1Services/ServiceContainer.swift b/MC1Services/Sources/MC1Services/ServiceContainer.swift index af1f1f318..54d5d02a6 100644 --- a/MC1Services/Sources/MC1Services/ServiceContainer.swift +++ b/MC1Services/Sources/MC1Services/ServiceContainer.swift @@ -355,10 +355,6 @@ public final class ServiceContainer { await messageService.startEventMonitoring() await messageService.startAckExpiryChecking() - let meshSession = session - await remoteNodeService.setRadioClockProvider { [weak meshSession] in - try? await meshSession?.getTime() - } await remoteNodeService.startEventMonitoring() // Always start message event monitoring so handlers are ready for polled messages diff --git a/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift b/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift index fc7234e6d..bc9855311 100644 --- a/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift +++ b/MC1Services/Sources/MC1Services/Services/NodeSettingsResponseParser.swift @@ -34,6 +34,14 @@ public enum NodeSettingsResponseParser { private static let clockResponseDateFormat = "HH:mm d/M/yyyy" + /// The UTC clock shape in firmware text, including inside an `OK - clock set:` reply. + public static func clockResponseText(in text: String) -> String? { + // 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 = text.firstMatch(of: clockResponseRegex) else { return nil } + return String(match.output.0) + } + /// 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? { @@ -48,6 +56,15 @@ public enum NodeSettingsResponseParser { return formatter.date(from: "\(match.output.1) \(match.output.2)") } + /// Seconds the node's clock is ahead of `now`. Nil when `response` has no clock shape. + public static func clockDrift( + fromClockResponse response: String, + relativeTo now: Date + ) -> TimeInterval? { + guard let nodeDate = utcDate(fromClockResponse: response) else { return nil } + return nodeDate.timeIntervalSince(now) + } + // MARK: - Clock Sync /// Outcome of a `clock sync` command response. diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift index e958f2889..9a076a4c8 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService+Login.swift @@ -235,13 +235,6 @@ public extension RemoteNodeService { eventBroadcaster.yield(.sessionStateChanged(sessionID: remoteSession.id, isConnected: true)) keepAliveIntervals[remoteSession.id] = Self.defaultKeepAliveInterval - // Detached from the login path: reading the radio clock is a BLE - // round trip that must not delay the login continuation. - let sessionID = remoteSession.id - let serverTime = result.serverTime - Task { [weak self] in - await self?.recordLoginClockDrift(sessionID: sessionID, serverTime: serverTime) - } } catch { logger.error("handleLoginResult: failed to update session state: \(error)") } diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift index 9228aa63e..dcb2e5b18 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift @@ -52,11 +52,6 @@ public actor RemoteNodeService { /// Cycling counter for CLI wire prefixes ("00|" through "FF|"). var cliPrefixCounter: UInt8 = 0 - /// Clock drift measured at the last successful login, keyed by session ID. - /// Positive means the remote node's clock is ahead of the connected radio. - /// In-memory only; drift is re-measured on every login. - var loginClockDrifts: [UUID: TimeInterval] = [:] - /// A task queued for a node's CLI slot while another command is in flight. struct CLISlotWaiter { let id: UUID @@ -69,11 +64,6 @@ public actor RemoteNodeService { /// FIFO waiters for a node's CLI slot, keyed by 6-byte public key prefix. var cliSlotWaiters: [Data: [CLISlotWaiter]] = [:] - /// Reads the connected radio's clock; wired by `ServiceContainer`. Clock - /// drift is measured against the radio, not the phone, because mesh packet - /// timestamps come from the radio's RTC. - var radioClockProvider: (@Sendable () async -> Date?)? - /// Keep-alive timer tasks var keepAliveTasks: [UUID: Task] = [:] @@ -248,29 +238,6 @@ public actor RemoteNodeService { return String(format: "%02X%@", cliPrefixCounter, String(CLIResponse.echoPrefixSeparator)) } - // MARK: - Login Clock Drift - - /// Sets the closure used to read the connected radio's clock. - public func setRadioClockProvider(_ provider: @escaping @Sendable () async -> Date?) { - radioClockProvider = provider - } - - /// Records the clock drift measured from a login response, relative to the - /// radio's clock (falling back to the phone when the radio can't be read). - func recordLoginClockDrift(sessionID: UUID, serverTime: Date?) async { - guard let serverTime else { return } - let reference = await radioClockProvider?() ?? Date() - let drift = serverTime.timeIntervalSince(reference) - loginClockDrifts[sessionID] = drift - logger.info("Login clock drift for session \(sessionID): \(Int(drift))s") - } - - /// The remote node's clock drift measured at its last login this connection. - /// Positive means the node's clock is ahead of the connected radio. - public func loginClockDrift(sessionID: UUID) -> TimeInterval? { - loginClockDrifts[sessionID] - } - func timeInterval(for duration: Duration) -> TimeInterval { let (seconds, attoseconds) = duration.components return TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18 diff --git a/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift b/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift index a6f32d038..ffefc6670 100644 --- a/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/NodeSettingsResponseParserTests.swift @@ -79,6 +79,38 @@ struct NodeSettingsResponseParserTests { #expect(NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025") == nil) } + @Test + func `clock response text is extracted from a bare clock line and from an OK sync line`() { + #expect( + NodeSettingsResponseParser.clockResponseText(in: "06:40 - 18/4/2025 UTC") + == "06:40 - 18/4/2025 UTC" + ) + #expect( + NodeSettingsResponseParser.clockResponseText(in: "OK - clock set: 15:35 - 14/8/2026 UTC") + == "15:35 - 14/8/2026 UTC" + ) + #expect(NodeSettingsResponseParser.clockResponseText(in: "OK - clock set") == nil) + #expect(NodeSettingsResponseParser.clockResponseText(in: "Alpha Repeater") == nil) + } + + @Test + func `clock drift is node minus reference and nil when the text has no clock`() throws { + let node = try #require( + NodeSettingsResponseParser.utcDate(fromClockResponse: "06:40 - 18/4/2025 UTC") + ) + let now = node.addingTimeInterval(600) + let drift = NodeSettingsResponseParser.clockDrift( + fromClockResponse: "06:40 - 18/4/2025 UTC", + relativeTo: now + ) + #expect(drift == -600) + + #expect( + NodeSettingsResponseParser.clockDrift(fromClockResponse: "OK - clock set", relativeTo: now) + == nil + ) + } + // MARK: - Clock Sync @Test diff --git a/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift b/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift index 87443ea6f..4bee134f7 100644 --- a/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift +++ b/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift @@ -275,9 +275,10 @@ struct NodeSettingsClockSyncTests { @MainActor final class CommandRecorder { private(set) var commands: [String] = [] + var reply = "OK - clock set: 15:35 - 14/8/2026 UTC" func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { commands.append(command) - return "OK - clock set: 15:35 - 14/8/2026 UTC" + return reply } } @@ -311,4 +312,45 @@ struct NodeSettingsClockSyncTests { let epoch = try #require(UInt32(epochText)) #expect(epoch >= before && epoch <= after) } + + @Test + func `fetchDeviceInfo records drift from clock against a fixed now`() async throws { + let recorder = CommandRecorder() + recorder.reply = "06:40 - 18/4/2025 UTC" + let viewModel = makeConfiguredViewModel(recorder: recorder) + let node = try #require(NodeSettingsResponseParser.utcDate(fromClockResponse: recorder.reply)) + viewModel.now = { node.addingTimeInterval(3600) } + + await viewModel.fetchDeviceInfo() + + #expect(viewModel.clockDrift == -3600) + } + + @Test + func `syncTime on OK with clock text updates device time and drift`() async throws { + let recorder = CommandRecorder() + recorder.reply = "OK - clock set: 15:35 - 14/8/2026 UTC" + let viewModel = makeConfiguredViewModel(recorder: recorder) + let node = try #require(NodeSettingsResponseParser.utcDate(fromClockResponse: recorder.reply)) + viewModel.now = { node } + + await viewModel.syncTime() + + #expect(recorder.commands.count == 1) + #expect(viewModel.clockDrift == 0) + #expect(viewModel.deviceTime != nil) + } + + @Test + func `syncTime on bare OK hides the warning without a second command`() async { + let recorder = CommandRecorder() + recorder.reply = "OK - clock set" + let viewModel = makeConfiguredViewModel(recorder: recorder) + viewModel.clockDrift = -10000 + + await viewModel.syncTime() + + #expect(recorder.commands.count == 1) + #expect(viewModel.clockDrift == nil) + } } From d96e4197fd303535f21580e1d67e52420e8cc945 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:01:50 -0700 Subject: [PATCH 11/47] fix(repeaters): add region as flood-allowed - Store new regions with flood allowed, same as firmware put - Show CLI errors in the Regions section - Validate the name in the add alert before sending --- MC1/Resources/Generated/L10n.swift | 8 ++ .../Localization/de.lproj/RemoteNodes.strings | 9 ++ .../Localization/en.lproj/RemoteNodes.strings | 9 ++ .../Localization/es.lproj/RemoteNodes.strings | 9 ++ .../Localization/fr.lproj/RemoteNodes.strings | 9 ++ .../Localization/it.lproj/RemoteNodes.strings | 9 ++ .../Localization/nl.lproj/RemoteNodes.strings | 9 ++ .../Localization/pl.lproj/RemoteNodes.strings | 9 ++ .../Localization/ru.lproj/RemoteNodes.strings | 9 ++ .../Localization/uk.lproj/RemoteNodes.strings | 9 ++ .../zh-Hans.lproj/RemoteNodes.strings | 9 ++ .../Repeaters/RepeaterSettingsView.swift | 40 ++++++- .../Repeaters/RepeaterSettingsViewModel.swift | 5 +- ...RepeaterSettingsViewModelRegionTests.swift | 103 ++++++++++++++++++ 14 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index b6e72315f..1b9cf70be 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -3566,12 +3566,20 @@ public enum L10n { public static let allTraffic = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTraffic", fallback: "All Traffic") /// Location: RepeaterSettingsView.swift - Wildcard with asterisk display public static let allTrafficWildcard = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTrafficWildcard", fallback: "* (All Traffic)") + /// Location: RepeaterSettingsView.swift - Duplicate region name validation + public static let duplicate = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.duplicate", fallback: "This region already exists.") /// Location: RepeaterSettingsViewModel.swift - No regions on device public static let empty = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.empty", fallback: "No regions configured") /// Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle public static let floodToggleHint = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.floodToggleHint", fallback: "When off, flood packets from this region are dropped") /// Location: RepeaterSettingsView.swift - Home region picker label public static let homeRegion = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.homeRegion", fallback: "Home Region") + /// Location: RepeaterSettingsView.swift - Region name charset validation + public static let invalidName = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.invalidName", fallback: "Region names can only contain letters, numbers, and hyphens.") + /// Location: RepeaterSettingsView.swift - Region name length validation + public static func nameTooLong(_ p1: Int) -> String { + return L10n.tr("RemoteNodes", "remoteNodes.settings.regions.nameTooLong", p1, fallback: "Region names are limited to %d bytes.") + } /// Location: RepeaterSettingsViewModel.swift - Region has children error public static let notEmpty = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.notEmpty", fallback: "Remove child regions first") /// Location: RepeaterSettingsView.swift - Region name placeholder diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 6f59a00a0..0b535f009 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -388,6 +388,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Region konnte nicht hinzugefügt werden"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Regionsnamen dürfen nur Buchstaben, Zahlen und Bindestriche enthalten."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Regionsnamen sind auf %d Bytes begrenzt."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Diese Region existiert bereits."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Region konnte nicht entfernt werden"; diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index f0484f7df..b18f48530 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -388,6 +388,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Failed to add region"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Region names can only contain letters, numbers, and hyphens."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Region names are limited to %d bytes."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "This region already exists."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Failed to remove region"; diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 3a4cf39e1..00024d02b 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -388,6 +388,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Error al agregar la región"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Los nombres de región solo pueden contener letras, números y guiones."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Los nombres de región están limitados a %d bytes."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Esta región ya existe."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Error al eliminar la región"; diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index e06c0f007..8c3093d70 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -388,6 +388,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Échec de l'ajout de la région"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Les noms de région ne peuvent contenir que des lettres, des chiffres et des traits d'union."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Les noms de région sont limités à %d octets."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Cette région existe déjà."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Échec de la suppression de la région"; diff --git a/MC1/Resources/Localization/it.lproj/RemoteNodes.strings b/MC1/Resources/Localization/it.lproj/RemoteNodes.strings index 1e92ec8be..b0598a2cd 100644 --- a/MC1/Resources/Localization/it.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/it.lproj/RemoteNodes.strings @@ -388,6 +388,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Aggiunta della region non riuscita"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "I nomi delle region possono contenere solo lettere, numeri e trattini."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "I nomi delle region sono limitati a %d byte."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Questa region esiste già."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Rimozione della region non riuscita"; diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index cca016675..5dbd68529 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -388,6 +388,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Kan regio niet toevoegen"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Regionamen mogen alleen letters, cijfers en koppeltekens bevatten."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Regionamen zijn beperkt tot %d bytes."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Deze regio bestaat al."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Kan regio niet verwijderen"; diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 7c24f8269..b293f351c 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -385,6 +385,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Nie udało się dodać regionu"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Nazwy regionów mogą zawierać tylko litery, cyfry i myślniki."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Nazwy regionów są ograniczone do %d bajtów."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Ten region już istnieje."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Nie udało się usunąć regionu"; diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index de8fc28f0..bb7427de0 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -385,6 +385,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Не удалось добавить регион"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Названия регионов могут содержать только буквы, цифры и дефисы."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Названия регионов ограничены %d байтами."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Этот регион уже существует."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Не удалось удалить регион"; diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index a4daa9b96..8d8004364 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -385,6 +385,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "Не вдалося додати регіон"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Назви регіонів можуть містити лише літери, цифри та дефіси."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Назви регіонів обмежені %d байтами."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Цей регіон уже існує."; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "Не вдалося видалити регіон"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 9c1bafa35..2573a7f7b 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -388,6 +388,15 @@ /* Location: RepeaterSettingsViewModel.swift - Region add failure */ "remoteNodes.settings.regions.addFailed" = "添加区域失败"; +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "区域名称只能包含字母、数字和连字符。"; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "区域名称限制为 %d 字节。"; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "此区域已存在。"; + /* Location: RepeaterSettingsViewModel.swift - Region remove failure */ "remoteNodes.settings.regions.removeFailed" = "移除区域失败"; diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift index ecb01d70e..1b4c2f0b0 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift @@ -336,6 +336,9 @@ private struct BehaviorSection: View { private struct RegionsSection: View { @Bindable var viewModel: RepeaterSettingsViewModel + @State private var showingAddAlert = false + @State private var newRegionName = "" + @State private var validationMessage: String? /// Regions sorted: wildcard first, then alphabetical private var sortedRegions: [RepeaterRegionEntry] { @@ -419,7 +422,8 @@ private struct RegionsSection: View { // Add region button Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion, systemImage: "plus") { - viewModel.isAddingRegion = true + newRegionName = "" + showingAddAlert = true } .disabled(viewModel.helper.isApplying) @@ -436,17 +440,43 @@ private struct RegionsSection: View { } .disabled(viewModel.helper.isApplying || viewModel.regionsSaveSuccess || !viewModel.hasUnsavedRegionChanges) } + + if let error = viewModel.helper.errorMessage { + Text(error) + .foregroundStyle(.orange) + .font(.caption) + } } - .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $viewModel.isAddingRegion) { - TextField(L10n.RemoteNodes.RemoteNodes.Settings.Regions.regionName, text: $viewModel.newRegionName) + .alert(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegionTitle, isPresented: $showingAddAlert) { + TextField(L10n.RemoteNodes.RemoteNodes.Settings.Regions.regionName, text: $newRegionName) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button(L10n.RemoteNodes.RemoteNodes.Settings.Regions.addRegion) { - Task { await viewModel.addRegion(name: viewModel.newRegionName) } + if let error = RegionNameValidator.validate(newRegionName, existingRegions: viewModel.regions.map(\.name)) { + validationMessage = validationText(for: error) + Task { showingAddAlert = true } + return + } + validationMessage = nil + let name = newRegionName.trimmingCharacters(in: .whitespaces) + Task { await viewModel.addRegion(name: name) } } Button(L10n.RemoteNodes.RemoteNodes.cancel, role: .cancel) { - viewModel.newRegionName = "" + validationMessage = nil } + } message: { + if let validationMessage { + Text(validationMessage) + } + } + } + + private func validationText(for error: RegionNameValidator.ValidationError) -> String? { + switch error { + case .empty: nil + case .invalidCharacters: L10n.RemoteNodes.RemoteNodes.Settings.Regions.invalidName + case let .tooLong(maxBytes): L10n.RemoteNodes.RemoteNodes.Settings.Regions.nameTooLong(maxBytes) + case .duplicate: L10n.RemoteNodes.RemoteNodes.Settings.Regions.duplicate } } } diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift index f51ecdf23..1100dea7f 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift @@ -50,8 +50,6 @@ final class RepeaterSettingsViewModel { } var hasUnsavedRegionChanges = false - var isAddingRegion = false - var newRegionName = "" var regionsSaveSuccess = false // MARK: - Expansion State (repeater-only sections) @@ -431,11 +429,10 @@ final class RepeaterSettingsViewModel { if case .ok = CLIResponse.parse(response) { regions.append(RepeaterRegionEntry( name: trimmed, - floodAllowed: false, + floodAllowed: true, isHome: false )) hasUnsavedRegionChanges = true - newRegionName = "" } else { helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed } diff --git a/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift b/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift new file mode 100644 index 000000000..2696598c9 --- /dev/null +++ b/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift @@ -0,0 +1,103 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +/// A successful `region put` stores `floodAllowed: true`. Firmware sets flags to 0 on put. +@Suite("RepeaterSettingsViewModel add region") +@MainActor +struct RepeaterSettingsRegionTests { + @MainActor + final class CommandRecorder { + private(set) var commands: [String] = [] + var reply: String = "OK - (flood allowed)" + + func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { + commands.append(command) + return reply + } + } + + private func makeViewModel(recorder: CommandRecorder) -> RepeaterSettingsViewModel { + let viewModel = RepeaterSettingsViewModel() + viewModel.helper.configure( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Repeater", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ), + sendCommand: recorder.send, + sendRawCommand: recorder.send + ) + return viewModel + } + + @Test + func `firmware flood-allow put reply adds the region as flood allowed`() async { + let recorder = CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: "Europe") + + #expect(recorder.commands == ["region put Europe"]) + #expect(viewModel.helper.errorMessage == nil) + #expect(viewModel.regions.count == 1) + #expect(viewModel.regions.first?.name == "Europe") + #expect(viewModel.regions.first?.floodAllowed == true) + #expect(viewModel.hasUnsavedRegionChanges) + } + + @Test + func `legacy OK put reply still adds the region as flood allowed`() async { + let recorder = CommandRecorder() + recorder.reply = "OK" + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: "UK") + + #expect(viewModel.regions.first?.name == "UK") + #expect(viewModel.regions.first?.floodAllowed == true) + } + + @Test + func `empty name is a silent no-op`() async { + let recorder = CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: " ") + + #expect(recorder.commands.isEmpty) + #expect(viewModel.regions.isEmpty) + #expect(viewModel.helper.errorMessage == nil) + #expect(!viewModel.hasUnsavedRegionChanges) + } + + @Test + func `invalid name sets addFailed and does not send`() async { + let recorder = CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: "my region") + + #expect(recorder.commands.isEmpty) + #expect(viewModel.regions.isEmpty) + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed) + } + + @Test + func `non-OK put reply sets addFailed and does not append`() async { + let recorder = CommandRecorder() + recorder.reply = "Err - unable to put" + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.addRegion(name: "Europe") + + #expect(recorder.commands == ["region put Europe"]) + #expect(viewModel.regions.isEmpty) + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.addFailed) + #expect(!viewModel.hasUnsavedRegionChanges) + } +} From b64ec9920d9affeee21ebdfd2b54f631ef651cb9 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:03:06 -0700 Subject: [PATCH 12/47] fix(settings): defer region add until the alert dismisses --- MC1/Views/Components/RegionManagementView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MC1/Views/Components/RegionManagementView.swift b/MC1/Views/Components/RegionManagementView.swift index ecbbdb3a8..d25ff5710 100644 --- a/MC1/Views/Components/RegionManagementView.swift +++ b/MC1/Views/Components/RegionManagementView.swift @@ -57,7 +57,8 @@ struct RegionManagementView: View { return } validationMessage = nil - onAddRegion(newRegionName.trimmingCharacters(in: .whitespaces)) + let name = newRegionName.trimmingCharacters(in: .whitespaces) + Task { onAddRegion(name) } } Button(L10n.Chats.Chats.Common.cancel, role: .cancel) { validationMessage = nil From 57397a3c28dd8abbabb2e83184a2ffc438dd77de Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:32:07 -0700 Subject: [PATCH 13/47] ui(regions): label * as unscoped - * is a packet with no region, not all traffic --- MC1/Resources/Generated/L10n.swift | 10 +++++----- MC1/Resources/Localization/de.lproj/Chats.strings | 2 +- .../Localization/de.lproj/RemoteNodes.strings | 8 ++++---- MC1/Resources/Localization/en.lproj/Chats.strings | 2 +- .../Localization/en.lproj/RemoteNodes.strings | 8 ++++---- MC1/Resources/Localization/es.lproj/Chats.strings | 2 +- .../Localization/es.lproj/RemoteNodes.strings | 8 ++++---- MC1/Resources/Localization/fr.lproj/Chats.strings | 2 +- .../Localization/fr.lproj/RemoteNodes.strings | 8 ++++---- MC1/Resources/Localization/it.lproj/Chats.strings | 2 +- .../Localization/it.lproj/RemoteNodes.strings | 8 ++++---- MC1/Resources/Localization/nl.lproj/Chats.strings | 2 +- .../Localization/nl.lproj/RemoteNodes.strings | 8 ++++---- MC1/Resources/Localization/pl.lproj/Chats.strings | 2 +- .../Localization/pl.lproj/RemoteNodes.strings | 8 ++++---- MC1/Resources/Localization/ru.lproj/Chats.strings | 2 +- .../Localization/ru.lproj/RemoteNodes.strings | 8 ++++---- MC1/Resources/Localization/uk.lproj/Chats.strings | 2 +- .../Localization/uk.lproj/RemoteNodes.strings | 8 ++++---- MC1/Resources/Localization/zh-Hans.lproj/Chats.strings | 2 +- .../Localization/zh-Hans.lproj/RemoteNodes.strings | 8 ++++---- 21 files changed, 55 insertions(+), 55 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 1b9cf70be..f15b9b5cb 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -137,7 +137,7 @@ public enum L10n { /// Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button public static let addSelected = L10n.tr("Chats", "chats.channelInfo.region.addSelected", fallback: "Add") /// Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set - public static let allRegions = L10n.tr("Chats", "chats.channelInfo.region.allRegions", fallback: "All Regions") + public static let allRegions = L10n.tr("Chats", "chats.channelInfo.region.allRegions", fallback: "Unscoped") /// Location: ChannelInfoSheet.swift - Purpose: Discover button public static let discover = L10n.tr("Chats", "chats.channelInfo.region.discover", fallback: "Discover Nearby Regions") /// Location: ChannelInfoSheet.swift - Purpose: Discover button loading state @@ -3562,10 +3562,10 @@ public enum L10n { public static let addRegion = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.addRegion", fallback: "Add Region") /// Location: RepeaterSettingsView.swift - Add region alert title public static let addRegionTitle = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.addRegionTitle", fallback: "Add Region") - /// Location: RepeaterSettingsView.swift - Wildcard region display name - public static let allTraffic = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTraffic", fallback: "All Traffic") - /// Location: RepeaterSettingsView.swift - Wildcard with asterisk display - public static let allTrafficWildcard = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTrafficWildcard", fallback: "* (All Traffic)") + /// Location: RepeaterSettingsView.swift - Unscoped region display name + public static let allTraffic = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTraffic", fallback: "Unscoped") + /// Location: RepeaterSettingsView.swift - Unscoped region with asterisk display + public static let allTrafficWildcard = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTrafficWildcard", fallback: "* (Unscoped)") /// Location: RepeaterSettingsView.swift - Duplicate region name validation public static let duplicate = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.duplicate", fallback: "This region already exists.") /// Location: RepeaterSettingsViewModel.swift - No regions on device diff --git a/MC1/Resources/Localization/de.lproj/Chats.strings b/MC1/Resources/Localization/de.lproj/Chats.strings index 24f8fcd4e..feca69582 100644 --- a/MC1/Resources/Localization/de.lproj/Chats.strings +++ b/MC1/Resources/Localization/de.lproj/Chats.strings @@ -1125,7 +1125,7 @@ "chats.channelInfo.region" = "Region"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Alle Regionen"; +"chats.channelInfo.region.allRegions" = "Ohne Bereich"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (Standard)"; diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 0b535f009..72a9715e5 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -349,11 +349,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Im Repeater speichern, um Änderungen über Neustarts hinweg zu behalten."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Gesamter Verkehr"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Ohne Bereich"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Gesamter Verkehr)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Ohne Bereich)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Heimatregion"; diff --git a/MC1/Resources/Localization/en.lproj/Chats.strings b/MC1/Resources/Localization/en.lproj/Chats.strings index 08d1fb6d9..3ba06c1ec 100644 --- a/MC1/Resources/Localization/en.lproj/Chats.strings +++ b/MC1/Resources/Localization/en.lproj/Chats.strings @@ -1131,7 +1131,7 @@ "chats.channelInfo.region" = "Region"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "All Regions"; +"chats.channelInfo.region.allRegions" = "Unscoped"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (default)"; diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index b18f48530..8e9650c72 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -349,11 +349,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Save to Repeater to keep changes across restarts."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "All Traffic"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Unscoped"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (All Traffic)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Unscoped)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Home Region"; diff --git a/MC1/Resources/Localization/es.lproj/Chats.strings b/MC1/Resources/Localization/es.lproj/Chats.strings index 31da99421..e79fb66ec 100644 --- a/MC1/Resources/Localization/es.lproj/Chats.strings +++ b/MC1/Resources/Localization/es.lproj/Chats.strings @@ -1121,7 +1121,7 @@ "chats.channelInfo.region" = "Región"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Todas las regiones"; +"chats.channelInfo.region.allRegions" = "Sin región"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (predeterminado)"; diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 00024d02b..992e8f105 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -349,11 +349,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Usa Guardar en el repetidor para conservar los cambios tras reiniciar."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Todo el tráfico"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Sin región"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Todo el tráfico)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Sin región)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Región local"; diff --git a/MC1/Resources/Localization/fr.lproj/Chats.strings b/MC1/Resources/Localization/fr.lproj/Chats.strings index 8860e4741..3dded2494 100644 --- a/MC1/Resources/Localization/fr.lproj/Chats.strings +++ b/MC1/Resources/Localization/fr.lproj/Chats.strings @@ -1121,7 +1121,7 @@ "chats.channelInfo.region" = "Région"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Toutes les régions"; +"chats.channelInfo.region.allRegions" = "Portée non assignée"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (par défaut)"; diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index 8c3093d70..20ad48924 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -349,11 +349,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Enregistrez sur le répéteur pour conserver les modifications après les redémarrages."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Tout le trafic"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Portée non assignée"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Tout le trafic)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Portée non assignée)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Région d'origine"; diff --git a/MC1/Resources/Localization/it.lproj/Chats.strings b/MC1/Resources/Localization/it.lproj/Chats.strings index ab564040d..4d3e482db 100644 --- a/MC1/Resources/Localization/it.lproj/Chats.strings +++ b/MC1/Resources/Localization/it.lproj/Chats.strings @@ -1131,7 +1131,7 @@ "chats.channelInfo.region" = "Region"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Tutte le region"; +"chats.channelInfo.region.allRegions" = "Senza ambito"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (predefinita)"; diff --git a/MC1/Resources/Localization/it.lproj/RemoteNodes.strings b/MC1/Resources/Localization/it.lproj/RemoteNodes.strings index b0598a2cd..6c97c3a39 100644 --- a/MC1/Resources/Localization/it.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/it.lproj/RemoteNodes.strings @@ -349,11 +349,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Salva sul ripetitore per mantenere le modifiche tra i riavvii."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Tutto il traffico"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Senza ambito"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Tutto il traffico)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Senza ambito)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "region principale"; diff --git a/MC1/Resources/Localization/nl.lproj/Chats.strings b/MC1/Resources/Localization/nl.lproj/Chats.strings index bf03feb60..ca207e225 100644 --- a/MC1/Resources/Localization/nl.lproj/Chats.strings +++ b/MC1/Resources/Localization/nl.lproj/Chats.strings @@ -1123,7 +1123,7 @@ "chats.channelInfo.region" = "Regio"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Alle regio's"; +"chats.channelInfo.region.allRegions" = "Zonder regio"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (standaard)"; diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index 5dbd68529..e4ad48377 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -349,11 +349,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Sla op naar repeater om wijzigingen na herstart te behouden."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Al het verkeer"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Zonder regio"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Al het verkeer)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Zonder regio)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Thuisregio"; diff --git a/MC1/Resources/Localization/pl.lproj/Chats.strings b/MC1/Resources/Localization/pl.lproj/Chats.strings index 2600939ef..db234f607 100644 --- a/MC1/Resources/Localization/pl.lproj/Chats.strings +++ b/MC1/Resources/Localization/pl.lproj/Chats.strings @@ -1117,7 +1117,7 @@ "chats.channelInfo.region" = "Region"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Wszystkie regiony"; +"chats.channelInfo.region.allRegions" = "Bez regionu"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (domyślny)"; diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index b293f351c..83cf2ee26 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -346,11 +346,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Zapisz w przekaźniku, aby zachować zmiany po restartach."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Cały ruch"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Bez regionu"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Cały ruch)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Bez regionu)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Region domowy"; diff --git a/MC1/Resources/Localization/ru.lproj/Chats.strings b/MC1/Resources/Localization/ru.lproj/Chats.strings index c33d5965e..5f143cadb 100644 --- a/MC1/Resources/Localization/ru.lproj/Chats.strings +++ b/MC1/Resources/Localization/ru.lproj/Chats.strings @@ -1114,7 +1114,7 @@ "chats.channelInfo.region" = "Регион"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Все регионы"; +"chats.channelInfo.region.allRegions" = "Без области"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (по умолчанию)"; diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index bb7427de0..3866de498 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -346,11 +346,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Нажмите «Сохранить на ретранслятор», чтобы сохранить изменения после перезагрузки."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Весь трафик"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Без области"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Весь трафик)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Без области)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Домашний регион"; diff --git a/MC1/Resources/Localization/uk.lproj/Chats.strings b/MC1/Resources/Localization/uk.lproj/Chats.strings index 1f9d629fd..e1c5c4d97 100644 --- a/MC1/Resources/Localization/uk.lproj/Chats.strings +++ b/MC1/Resources/Localization/uk.lproj/Chats.strings @@ -1116,7 +1116,7 @@ "chats.channelInfo.region" = "Регіон"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Усі регіони"; +"chats.channelInfo.region.allRegions" = "Без області"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (за замовчуванням)"; diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index 8d8004364..1a8a387d6 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -346,11 +346,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "Збережіть на ретранслятор, щоб зберегти зміни після перезавантажень."; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "Увесь трафік"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Без області"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Увесь трафік)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Без області)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Домашній регіон"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings index 899fb8c9a..1b7bf00aa 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings @@ -1123,7 +1123,7 @@ "chats.channelInfo.region" = "区域"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "所有区域"; +"chats.channelInfo.region.allRegions" = "无范围"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@(默认)"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index 2573a7f7b..ca7a72847 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -349,11 +349,11 @@ /* Location: RepeaterSettingsView.swift - Regions section footer */ "remoteNodes.settings.regionsFooter" = "保存到转发节点以在重启后保留更改。"; -/* Location: RepeaterSettingsView.swift - Wildcard region display name */ -"remoteNodes.settings.regions.allTraffic" = "所有流量"; +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "无范围"; -/* Location: RepeaterSettingsView.swift - Wildcard with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "*(所有流量)"; +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "*(无范围)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "主区域"; From 4b9b6c42a1ddfe8f676f460b214d75990cc211cd Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:20:45 -0700 Subject: [PATCH 14/47] fix(chats): don't yank a scrolled-up thread - Scroll-to-bottom requests only pin when already at the bottom - Outgoing and self-sent rows no longer raise the unread badge --- .../ChatConversationMessagesContent.swift | 1 + .../Chats/Components/ChatTiledView.swift | 17 +- .../Rooms/RoomConversationView.swift | 3 +- .../ChatTiledViewScrollRequestTests.swift | 335 ++++++++++++++++++ 4 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 MC1Tests/Views/Chats/Components/ChatTiledViewScrollRequestTests.swift diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index 9a857a6d5..bbc826da9 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -80,6 +80,7 @@ struct ChatConversationMessagesContent: View { isAtBottom: $isAtBottom, unreadCount: $unreadCount, scrollToBottomRequest: scrollToBottomRequest, + countsTowardUnread: { !$0.envelope.isOutgoing }, scrollToTargetRequest: scrollToTargetRequest, scrollTargetID: scrollToTargetID, initialScrollTargetID: initialScrollTargetID, diff --git a/MC1/Views/Chats/Components/ChatTiledView.swift b/MC1/Views/Chats/Components/ChatTiledView.swift index 6b0ace8b9..d8bcd1b43 100644 --- a/MC1/Views/Chats/Components/ChatTiledView.swift +++ b/MC1/Views/Chats/Components/ChatTiledView.swift @@ -19,9 +19,13 @@ struct ChatTiledView: V @Binding var isAtBottom: Bool @Binding var unreadCount: Int - /// Bumped by callers to scroll to the visual bottom (e.g. on send). + /// Bumped by callers to pin the visual bottom. Honored only while `isAtBottom` + /// is already true; `ScrollToBottomButton` calls `scrollPosition.scrollTo` itself. var scrollToBottomRequest: Int = 0 + /// Returns whether an appended row raises the unread badge while scrolled up. + var countsTowardUnread: (Item) -> Bool = { _ in true } + /// Bumped by callers to jump to `scrollTargetID` (mention / reply / deeplink / divider). var scrollToTargetRequest: Int = 0 var scrollTargetID: Item.ID? @@ -56,6 +60,7 @@ struct ChatTiledView: V isAtBottom: Binding, unreadCount: Binding, scrollToBottomRequest: Int = 0, + countsTowardUnread: @escaping (Item) -> Bool = { _ in true }, scrollToTargetRequest: Int = 0, scrollTargetID: Item.ID? = nil, initialScrollTargetID: Item.ID? = nil, @@ -68,6 +73,7 @@ struct ChatTiledView: V _isAtBottom = isAtBottom _unreadCount = unreadCount self.scrollToBottomRequest = scrollToBottomRequest + self.countsTowardUnread = countsTowardUnread self.scrollToTargetRequest = scrollToTargetRequest self.scrollTargetID = scrollTargetID self.initialScrollTargetID = initialScrollTargetID @@ -123,7 +129,10 @@ struct ChatTiledView: V .padding(.trailing, 16) .padding(.bottom, 8) } - .onChange(of: scrollToBottomRequest) { scrollPosition.scrollTo(edge: .bottom) } + .onChange(of: scrollToBottomRequest) { + guard isAtBottom else { return } + scrollPosition.scrollTo(edge: .bottom, animated: false) + } .onChange(of: scrollToTargetRequest) { guard let id = scrollTargetID else { return } scrollPosition.scrollTo(id: id) @@ -132,8 +141,8 @@ struct ChatTiledView: V defer { newestID = latest } guard !isAtBottom, let previous = newestID, let previousIndex = items.firstIndex(where: { $0.id == previous }) else { return } - let appended = items.count - 1 - previousIndex - if appended > 0 { unreadCount += appended } + let incoming = items.suffix(from: previousIndex + 1).filter(countsTowardUnread).count + if incoming > 0 { unreadCount += incoming } } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index 73a9d3eae..90da05e69 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -338,7 +338,8 @@ private struct MessagesView: View { contentBackground: theme.surfaces?.canvas, isAtBottom: $isAtBottom, unreadCount: $unreadCount, - scrollToBottomRequest: scrollToBottomRequest + scrollToBottomRequest: scrollToBottomRequest, + countsTowardUnread: { !$0.isFromSelf } ) } } diff --git a/MC1Tests/Views/Chats/Components/ChatTiledViewScrollRequestTests.swift b/MC1Tests/Views/Chats/Components/ChatTiledViewScrollRequestTests.swift new file mode 100644 index 000000000..2986fec8f --- /dev/null +++ b/MC1Tests/Views/Chats/Components/ChatTiledViewScrollRequestTests.swift @@ -0,0 +1,335 @@ +@testable import MC1 +import SwiftUI +import Testing +import UIKit + +/// Holds bindings for a hosted `ChatTiledView` so mutations do not remount it. +@Observable +@MainActor +private final class ChatTiledViewScrollHarnessModel { + var rows: [ChatTiledViewScrollRequestTests.Row] + var isAtBottom = true + var unreadCount = 0 + var scrollToBottomRequest = 0 + + init(rows: [ChatTiledViewScrollRequestTests.Row]) { + self.rows = rows + } +} + +/// Hosted regressions for `ChatTiledView` token, send, and unread policy. +@Suite("ChatTiledView scroll-to-bottom request", .serialized) +@MainActor +struct ChatTiledViewScrollRequestTests { + struct Row: Identifiable, Hashable { + let id: UUID + let index: Int + var countsTowardUnread: Bool = true + } + + private struct RowCell: View { + let item: Row + var body: some View { + Color.blue.frame(height: Harness.rowHeight) + } + } + + private struct Harness: View { + @Bindable var model: ChatTiledViewScrollHarnessModel + + var body: some View { + ChatTiledView( + items: model.rows, + cellContent: { RowCell(item: $0) }, + isAtBottom: $model.isAtBottom, + unreadCount: $model.unreadCount, + scrollToBottomRequest: model.scrollToBottomRequest, + countsTowardUnread: { $0.countsTowardUnread } + ) + .ignoresSafeArea() + } + + static let rowHeight: CGFloat = 44 + } + + private static let viewportHeight: CGFloat = 600 + private static let scrollAwayPoints: CGFloat = 400 + private static let collectionWaitTimeout: TimeInterval = 5 + private static let runLoopSlice: TimeInterval = 0.05 + private static let afterRequestSettle: TimeInterval = 0.3 + private static let stayPutSlop: CGFloat = 1 + + private func makeRows(count: Int) -> [Row] { + (0.. (UIWindow, UIHostingController, UICollectionView) { + let controller = UIHostingController(rootView: Harness(model: model)) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: Self.viewportHeight)) + window.rootViewController = controller + window.isHidden = false + window.layoutIfNeeded() + let found = try #require(waitForCollectionView(in: window, itemCount: model.rows.count)) + return (window, controller, found.collectionView) + } + + private func waitForCollectionView( + in window: UIWindow, + itemCount: Int, + timeout: TimeInterval = collectionWaitTimeout + ) -> (collectionView: UICollectionView, messagesSection: Int)? { + let deadline = Date(timeIntervalSinceNow: timeout) + while Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopSlice)) + guard let collectionView = findCollectionView(in: window) else { continue } + for section in 0.. 0 { + return (collectionView, section) + } + } + } + return nil + } + + private func findCollectionView(in view: UIView) -> UICollectionView? { + if let collectionView = view as? UICollectionView { return collectionView } + for subview in view.subviews { + if let found = findCollectionView(in: subview) { return found } + } + return nil + } + + private func waitUntilNotAtBottom( + _ isAtBottom: @escaping () -> Bool, + timeout: TimeInterval = collectionWaitTimeout + ) -> Bool { + let deadline = Date(timeIntervalSinceNow: timeout) + while Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopSlice)) + if !isAtBottom() { return true } + } + return false + } + + private func waitUntilLastRowPinned( + _ found: (collectionView: UICollectionView, messagesSection: Int), + in rows: [Row], + timeout: TimeInterval = collectionWaitTimeout + ) -> CGFloat? { + let deadline = Date(timeIntervalSinceNow: timeout) + var lastBottom: CGFloat? + while Date() < deadline { + RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopSlice)) + guard let screenBottom = try? lastRowScreenBottom(found, in: rows) else { continue } + lastBottom = screenBottom + if abs(screenBottom - found.collectionView.bounds.height) < Self.stayPutSlop { + return screenBottom + } + } + return lastBottom + } + + private func lastRowScreenBottom( + _ found: (collectionView: UICollectionView, messagesSection: Int), + id: UUID? = nil, + in rows: [Row] + ) throws -> CGFloat { + let itemIndex: Int = if let id { + try #require(rows.firstIndex(where: { $0.id == id })) + } else { + found.collectionView.numberOfItems(inSection: found.messagesSection) - 1 + } + let attributes = try #require(found.collectionView.layoutAttributesForItem( + at: IndexPath(item: itemIndex, section: found.messagesSection) + )) + return attributes.frame.maxY - found.collectionView.contentOffset.y + } + + private func scrollAway(_ collectionView: UICollectionView) { + collectionView.setContentOffset( + CGPoint(x: 0, y: collectionView.contentOffset.y - Self.scrollAwayPoints), + animated: false + ) + } + + private func findScrollToBottomControl(in view: UIView) -> UIControl? { + let label = L10n.Chats.Chats.ScrollButton.ScrollToBottom.accessibilityLabel + if let control = view as? UIControl, view.accessibilityLabel == label { + return control + } + for subview in view.subviews { + if let found = findScrollToBottomControl(in: subview) { return found } + } + return nil + } + + private func findLabeledView(in view: UIView, label: String) -> UIView? { + if view.accessibilityLabel == label { return view } + for subview in view.subviews { + if let found = findLabeledView(in: subview, label: label) { return found } + } + return nil + } + + private func findAccessibleElement(in view: UIView, label: String) -> NSObject? { + if view.accessibilityLabel == label { return view } + if let elements = view.accessibilityElements { + for case let object as NSObject in elements where object.accessibilityLabel == label { + return object + } + } + let count = view.accessibilityElementCount() + if count != NSNotFound { + for index in 0..= 1) + #expect(!model.isAtBottom) + } + + @Test + func `button tap then send still pins the new last row`() throws { + let model = ChatTiledViewScrollHarnessModel(rows: makeRows(count: 60)) + let (window, _, _) = try mount(model: model) + defer { window.isHidden = true } + + let found = try #require(waitForCollectionView(in: window, itemCount: model.rows.count)) + scrollAway(found.collectionView) + try #require(waitUntilNotAtBottom { model.isAtBottom }) + + try tapScrollToBottomButton(in: window) + model.rows.append(Row(id: UUID(), index: model.rows.count)) + window.layoutIfNeeded() + let afterAppend = try #require(waitForCollectionView(in: window, itemCount: model.rows.count)) + let screenBottom = try #require(waitUntilLastRowPinned(afterAppend, in: model.rows)) + + #expect( + abs(screenBottom - afterAppend.collectionView.bounds.height) < Self.stayPutSlop, + "button tap plus append must pin the new last row (bottom \(screenBottom), viewport \(afterAppend.collectionView.bounds.height))" + ) + } +} From dd948aa735902a677c3f7d80134ffb48d4994042 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:33:58 -0700 Subject: [PATCH 15/47] fix(chats): keep timeline across reconnect - One process PersistenceStore; no second actor on connect - Registry survives disconnect and only clears on restore - Reconnect refreshes the loaded window instead of replacing it --- MC1/Intents/MessageTargetResolution.swift | 14 +- MC1/State/AppState+ChatPrefetch.swift | 2 +- MC1/State/AppState.swift | 98 +- MC1/State/ChatTimelinePrimer.swift | 2 +- MC1/Views/Chats/ChatConversationView.swift | 10 +- MC1/Views/Chats/ChatPopulateMode.swift | 10 + .../Chats/Timeline/ChatTimeline+Paging.swift | 218 ++-- MC1/Views/Chats/Timeline/ChatTimeline.swift | 17 +- .../ChatMessageBakeState+ItemBuild.swift | 13 +- .../ViewModel/ChatTimelinePopulator.swift | 61 +- .../ViewModel/ChatViewModel+Channels.swift | 21 +- .../ViewModel/ChatViewModel+Messages.swift | 22 +- MC1/Views/Settings/AppBackupViewModel.swift | 10 +- .../Sections/DiagnosticsSection.swift | 4 +- .../ConnectionManager+Lifecycle.swift | 17 +- .../ConnectionManager+Pairing.swift | 20 +- .../Connection/ConnectionManager+WiFi.swift | 2 +- .../Connection/ConnectionManager.swift | 28 +- .../Persistence/MessagePersisting.swift | 17 + .../MC1Services/ServiceContainer.swift | 25 +- .../Services/ChatCoordinator+Reload.swift | 96 +- .../Services/ChatCoordinator.swift | 45 +- .../Services/ChatCoordinatorRegistry.swift | 23 +- .../Services/ChatTimelineWriter.swift | 28 +- .../MessageDTO+ReactionVisibility.swift | 14 + .../PersistenceStore+BackupImport.swift | 3 + .../Services/PersistenceStore+Messages.swift | 90 ++ .../BackupIntegrationTests.swift | 97 +- .../Mocks/MockPersistenceStore.swift | 49 + .../PairingCancellationTests.swift | 4 +- .../PairingStrandedAssociationTests.swift | 4 +- .../PersistenceStoreMessageWindowTests.swift | 142 +++ .../ChatCoordinatorRegistryOfflineTests.swift | 25 - .../ChatCoordinatorRegistryTests.swift | 17 - ...ChatCoordinatorRegistrySurvivalTests.swift | 239 +++++ MC1Tests/Intents/SendMessageIntentTests.swift | 2 +- .../Services/InlineImagePrefetcherTests.swift | 17 + MC1Tests/Services/LinkPreviewCacheTests.swift | 17 + .../State/ChatPrewarmRefresherTests.swift | 4 +- .../State/ChatTimelineFreshnessTests.swift | 6 +- .../ViewModels/AppBackupViewModelTests.swift | 86 +- .../ChatViewModelPaginationTests.swift | 8 +- MC1Tests/ViewModels/ChatViewModelTests.swift | 4 +- .../LineOfSightViewModelTests.swift | 17 + ...RemoteNodeStatusHandlerSurvivalTests.swift | 2 +- .../ViewModels/TracePathListenerTests.swift | 2 +- .../Chats/ChatReconnectPopulateTests.swift | 996 ++++++++++++++++++ .../ChatTimelineClobberRegressionTests.swift | 4 +- MC1Tests/Views/Chats/ChatTimelineTests.swift | 59 +- .../Chats/ChatViewModelAdmissionTests.swift | 17 + .../Tools/CLI/CLIToolViewModelTests.swift | 17 + 51 files changed, 2286 insertions(+), 459 deletions(-) create mode 100644 MC1/Views/Chats/ChatPopulateMode.swift create mode 100644 MC1Services/Sources/MC1Services/Services/MessageDTO+ReactionVisibility.swift create mode 100644 MC1Services/Tests/MC1ServicesTests/PersistenceStoreMessageWindowTests.swift create mode 100644 MC1Tests/AppState/ChatCoordinatorRegistrySurvivalTests.swift create mode 100644 MC1Tests/Views/Chats/ChatReconnectPopulateTests.swift diff --git a/MC1/Intents/MessageTargetResolution.swift b/MC1/Intents/MessageTargetResolution.swift index 9c3cd2300..3ba372d04 100644 --- a/MC1/Intents/MessageTargetResolution.swift +++ b/MC1/Intents/MessageTargetResolution.swift @@ -3,20 +3,14 @@ import MC1Services // MARK: - Scope resolution -/// The radio and persistence store an intent query reads from. Resolves the -/// store explicitly because pickers populate while disconnected and in a -/// cold/background context with no live `ServiceContainer`: when connected the -/// live `dataStore`, otherwise a standalone store over the same open container. -/// A `nil` current radio (no connection and nothing last-connected, or a -/// pre-launch bridge with no `AppState`) yields nil, which every caller maps to -/// an empty result rather than an error. +/// The radio and persistence store an intent query reads from. Uses the +/// process-lifetime store on `ConnectionManager` because pickers populate +/// while disconnected. A `nil` radio yields nil; callers map that to empty. @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) + return (radioID, appState.connectionManager.persistenceStore) } // MARK: - Channel resolution diff --git a/MC1/State/AppState+ChatPrefetch.swift b/MC1/State/AppState+ChatPrefetch.swift index d57d287dc..c4807e1f7 100644 --- a/MC1/State/AppState+ChatPrefetch.swift +++ b/MC1/State/AppState+ChatPrefetch.swift @@ -152,7 +152,7 @@ extension AppState { /// Lazily builds the refresher that re-primes warm coordinators when /// messages arrive for closed conversations (see `ChatPrewarmRefresher`). /// Every hook resolves through `self` weakly at call time, so the refresher - /// stays valid across reconnects and registry rebinds. + /// stays valid across reconnects. func ensureChatPrewarmRefresher() -> ChatPrewarmRefresher { if let chatPrewarmRefresher { return chatPrewarmRefresher } let refresher = ChatPrewarmRefresher(hooks: ChatPrewarmRefresher.Hooks( diff --git a/MC1/State/AppState.swift b/MC1/State/AppState.swift index eacef1cbe..ceb8aa709 100644 --- a/MC1/State/AppState.swift +++ b/MC1/State/AppState.swift @@ -134,10 +134,8 @@ final class AppState { // 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. + /// Process-lifetime coordinator registry. Never nil'd after create — that + /// would strand a bound view model via `bindCoordinator`'s guard. private(set) var chatCoordinatorRegistry: ChatCoordinatorRegistry? /// Re-primes warm chat coordinators when messages arrive for closed @@ -155,37 +153,20 @@ final class AppState { /// and the shared decoded caches, same as the `\.linkPreviewCache` default. @ObservationIgnored lazy var backgroundLinkPreviewCache: any LinkPreviewCaching = LinkPreviewCache() - /// 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 + /// Process-lifetime store after a radio has been paired. Nil until then + /// so never-paired browsing stays empty. 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 + guard connectionManager.lastConnectedDeviceID != nil else { return nil } + return connectionManager.persistenceStore } - /// 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. + /// Sole lazy factory for the process-lifetime registry. Bound to the + /// process store; `wireServicesIfConnected` neither creates nor rebinds it. func ensureChatCoordinatorRegistry() -> ChatCoordinatorRegistry? { if let chatCoordinatorRegistry { return chatCoordinatorRegistry } guard let store = offlineDataStore else { return nil } @@ -226,18 +207,22 @@ final class AppState { } } - /// 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. + /// Bumps observer versions after a store-direct backup import so mounted + /// tabs reload. Also re-reads `regionSelection`, which `init` loads once. func notifyDataRestored() { contactsVersion += 1 conversationsVersion += 1 loadPersistedRegionSelection() themeService.refreshFromUserDefaults() + // Restore is refused while connected, so this cannot race a live session. + // Empties entries; the registry stays so later opens mint fresh coordinators. + chatCoordinatorRegistry?.clear() + bumpServicesVersion() + } + + /// Bumps `servicesVersion` so observing views re-run `performInitialLoad`. + func bumpServicesVersion() { + servicesVersion += 1 } // MARK: - Connection UI State @@ -347,9 +332,19 @@ final class AppState { // MARK: - Initialization - init(modelContainer: ModelContainer, isPlaceholder: Bool = false) { - let bootstrapStore = PersistenceStore(modelContainer: modelContainer) - let bootstrapBuffer = DebugLogBuffer(dataStore: bootstrapStore) + init( + modelContainer: ModelContainer, + isPlaceholder: Bool = false, + defaults: UserDefaults = .standard + ) { + let store = StoreService() + let theme = ThemeService(store: store) + storeState = StoreState(service: store) + themeService = theme + + connectionManager = ConnectionManager(modelContainer: modelContainer, defaults: defaults) + + let bootstrapBuffer = DebugLogBuffer(dataStore: connectionManager.persistenceStore) 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. @@ -357,13 +352,6 @@ final class AppState { DebugLogBuffer.shared = bootstrapBuffer } - 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 @@ -396,6 +384,13 @@ final class AppState { await self?.wireServicesIfConnected() } + connectionManager.onLastConnectedDeviceCleared = { [weak self] in + guard let self else { return } + chatCoordinatorRegistry?.clear() + refreshConversations() + bumpServicesVersion() + } + // 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 @@ -474,10 +469,9 @@ final class AppState { ) } - /// 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. + /// Per-session teardown for connection-loss and explicit disconnect. + /// Cancels event tasks; the coordinator registry stays so an open chat + /// keeps its timeline. func tearDownAppStateSessionState() { settingsEventsTask?.cancel() settingsEventsTask = nil @@ -488,8 +482,6 @@ final class AppState { rxLogEventsTask?.cancel() rxLogEventsTask = nil messageEventDispatcher.cancelAll() - chatCoordinatorRegistry?.tearDown() - chatCoordinatorRegistry = nil navigation.clearPendingLinks() } @@ -555,12 +547,6 @@ final class AppState { DemoInlineImageSeeder.seed() } - if let existing = chatCoordinatorRegistry { - existing.rebind(dataStore: services.dataStore) - } else { - chatCoordinatorRegistry = ChatCoordinatorRegistry(dataStore: services.dataStore) - } - wireSyncDataEvents(services: services) await wireSettingsEventStream(services: services) await wireDeviceUpdateCallbacks(services: services) diff --git a/MC1/State/ChatTimelinePrimer.swift b/MC1/State/ChatTimelinePrimer.swift index 85f268ddd..624209233 100644 --- a/MC1/State/ChatTimelinePrimer.swift +++ b/MC1/State/ChatTimelinePrimer.swift @@ -90,7 +90,7 @@ final class ChatTimelinePrimer { return ChatTimeline.ReactionIndexing(service: service, scope: scope) } - let outcome = await timeline.open(conversation, reactions: reactions) + let outcome = await timeline.open(conversation, reactions: reactions, populateMode: .replace) switch outcome { case .loaded: diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index ae5cf9a93..205e1aa95 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -224,12 +224,12 @@ struct ChatConversationView: View { chatViewModel: chatViewModel, onClearChannelMessages: { guard case let .channel(channel) = conversationType else { return } - await chatViewModel.loadChannelMessages(for: channel) + await chatViewModel.loadChannelMessages(for: channel, populateMode: .replace) parentViewModel?.requestConversationReload() }, onClearDirectMessages: { guard case let .dm(contact) = conversationType else { return } - await chatViewModel.loadMessages(for: contact) + await chatViewModel.loadMessages(for: contact, populateMode: .replace) parentViewModel?.requestConversationReload() }, onDeleteChannel: { @@ -358,7 +358,7 @@ struct ChatConversationView: View { switch conversationType { case let .dm(contact): - await chatViewModel.loadMessages(for: contact) + await chatViewModel.loadMessages(for: contact, populateMode: .refreshWindow) await chatViewModel.loadConversations(radioID: contact.radioID) await chatViewModel.loadAllContacts(radioID: contact.radioID) chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) @@ -366,7 +366,7 @@ struct ChatConversationView: View { 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.loadChannelMessages(for: channel, populateMode: .refreshWindow) await chatViewModel.loadConversations(radioID: channel.radioID) chatViewModel.restoreComposerDraft(from: appState.draftStore, id: conversationType.draftConversationID) } @@ -733,7 +733,7 @@ struct ChatConversationView: View { } if case let .channel(channel) = conversationType { - await chatViewModel.loadChannelMessages(for: channel) + await chatViewModel.loadChannelMessages(for: channel, populateMode: .replace) } services.syncCoordinator.notifyConversationsChanged() } diff --git a/MC1/Views/Chats/ChatPopulateMode.swift b/MC1/Views/Chats/ChatPopulateMode.swift new file mode 100644 index 000000000..f2f0f97cd --- /dev/null +++ b/MC1/Views/Chats/ChatPopulateMode.swift @@ -0,0 +1,10 @@ +import Foundation + +/// How a conversation load fills the timeline. +enum ChatPopulateMode { + /// Fetch the first page and replace the loaded window. + case replace + /// Refetch the already-loaded window in place. Falls back to `replace` + /// sizing when this conversation is not already presenting. + case refreshWindow +} diff --git a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift index f4e07d63b..6f3c38ec5 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift @@ -4,12 +4,13 @@ import MC1Services extension ChatTimeline { // MARK: - Populate - /// Populates the coordinator with the first page for `conversation` and - /// bakes render items, via the shared fetch → divider → filter → write → - /// bake sequence. Returns `.unavailable` when unbound. + /// Populates the coordinator for `conversation` and bakes render items. + /// Returns `.unavailable` when unbound. + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. func open( _ conversation: ChatConversationType, - reactions: ReactionIndexing? + reactions: ReactionIndexing?, + populateMode: ChatPopulateMode ) async -> ChatTimelinePopulator.Outcome { self.conversation = conversation // Every outcome settles: a failed or unavailable open has no divider @@ -22,6 +23,14 @@ extension ChatTimeline { if role == .interactive, openUnreadCount == 0 { bake.dividerComputed = true } + #if DEBUG + if let error = testPopulateError { + writer.beginLoading() + writer.markLoaded() + return .failed(error) + } + coordinator?.testPopulateFetchError = testPopulateFetchError + #endif let context = reactions.map { indexing in ChatTimelinePopulator.ReactionIndexingContext( reactionService: indexing.service, @@ -31,16 +40,49 @@ extension ChatTimeline { } ) } - return await ChatTimelinePopulator.populate( - conversation, - writer: writer, - dataStore: dataStoreProvider(), - bake: bake, - envInputs: envInputs, - senderTables: senderTablesProvider(), - reactions: context, - postApply: postApply - ) + guard let coordinator else { return .unavailable } + let outcome: ChatTimelinePopulator.Outcome + do { + outcome = try await writer.performWindowOperation { + try Task.checkCancellation() + // Compute the anchor inside the lane. An earlier loadOlder would + // extend the window; an enqueue-time anchor would truncate it. + let refreshWindow = populateMode == .refreshWindow + && coordinator.conversationID == conversation.coordinatorID + && coordinator.renderState.phase == .loaded + && !coordinator.messages.isEmpty + let anchorSortDate: Date? = refreshWindow + ? coordinator.messages.map(\.sortDate).min() + : nil + return await ChatTimelinePopulator.populate( + conversation, + writer: writer, + dataStore: dataStoreProvider(), + bake: bake, + envInputs: envInputs, + senderTables: senderTablesProvider(), + postApply: postApply, + anchorSortDate: anchorSortDate + ) + } + } catch is CancellationError { + return .cancelled + } catch { + return .failed(error) + } + if case .loaded = outcome, + let context, + let dataStore = dataStoreProvider() { + await ChatTimelinePopulator.indexMessagesForReactions( + coordinator.messages, + scope: context.scope, + reactionService: context.reactionService, + dataStore: dataStore, + writer: writer, + rebakeRow: context.rebakeRow + ) + } + return outcome } // MARK: - Paging @@ -49,73 +91,97 @@ extension ChatTimeline { /// rebakes. Returns the newly loaded messages (reaction-filtered and /// deduplicated) for caller-side bookkeeping such as sender registration /// and reaction indexing; empty when skipped (already loading, end of - /// history, unbound). Throws the fetch error after retiring the spinner. + /// history, or unbound). Fetch errors throw after the spinner retires. @discardableResult func loadOlder() async throws -> [MessageDTO] { - guard !renderState.isLoadingOlder, renderState.hasMoreMessages else { return [] } - guard let writer, let conversation, let dataStore = dataStoreProvider() else { return [] } - - writer.updateRenderState { $0.with(isLoadingOlder: true) } - + guard let coordinator, let writer, let conversation else { return [] } + guard let dataStore = dataStoreProvider() else { return [] } do { - let currentOffset = renderState.totalFetchedCount - var olderMessages: [MessageDTO] - let isDM: Bool - - switch conversation { - case let .dm(contact): - isDM = true - olderMessages = try await dataStore.fetchMessages( - contactID: contact.id, - limit: ChatCoordinator.pageSize, - offset: currentOffset - ) - case let .channel(channel): - isDM = false - olderMessages = try await dataStore.fetchMessages( - radioID: channel.radioID, - channelIndex: channel.index, - limit: ChatCoordinator.pageSize, - offset: currentOffset - ) - } - - // Offsets count unfiltered rows, so end-of-history and the next - // page's offset both derive from the raw fetch count. - let unfilteredCount = olderMessages.count - writer.updateRenderState { current in - current.with( - hasMoreMessages: unfilteredCount < ChatCoordinator.pageSize ? false : current.hasMoreMessages, - totalFetchedCount: current.totalFetchedCount + unfilteredCount - ) + return try await writer.performWindowOperation { + try Task.checkCancellation() + guard !coordinator.renderState.isLoadingOlder, + coordinator.renderState.hasMoreMessages else { return [] } + + writer.updateRenderState { $0.with(isLoadingOlder: true) } + + do { + let currentOffset = coordinator.renderState.totalFetchedCount + var olderMessages: [MessageDTO] + let isDM: Bool + + switch conversation { + case let .dm(contact): + isDM = true + olderMessages = try await dataStore.fetchMessages( + contactID: contact.id, + limit: ChatCoordinator.pageSize, + offset: currentOffset + ) + case let .channel(channel): + isDM = false + olderMessages = try await dataStore.fetchMessages( + radioID: channel.radioID, + channelIndex: channel.index, + limit: ChatCoordinator.pageSize, + offset: currentOffset + ) + } + + #if DEBUG + await loadOlderInterleaveHook?() + if let error = loadOlderTestError { + throw error + } + #endif + + try Task.checkCancellation() + + // Offsets count unfiltered rows, so end-of-history and the next + // page's offset both derive from the raw fetch count. + let unfilteredCount = olderMessages.count + writer.updateRenderState { current in + current.with( + hasMoreMessages: unfilteredCount < ChatCoordinator.pageSize ? false : current.hasMoreMessages, + totalFetchedCount: current.totalFetchedCount + unfilteredCount + ) + } + + olderMessages = bake.filterOutgoingReactionMessages(olderMessages, isDM: isDM) + + // An in-flight admission can land a message this fetch also carries; + // drop rows already present so the prepend cannot duplicate them. + let existingIDs = Set(coordinator.messages.map(\.id)) + olderMessages = olderMessages.filter { !existingIDs.contains($0.id) } + + // Re-run same-sender reordering so clusters split across the + // page boundary stay grouped. + writer.prepend(olderMessages) + let reordered = MessageDTO.reorderSameSenderClusters(coordinator.messages) + writer.replaceMessagesPreservingByID(reordered) + + // Retire the spinner before rebake. `updateRenderState` bumps + // `renderStateID`; doing it after would invalidate the just-launched build. + writer.updateRenderState { $0.with(isLoadingOlder: false) } + + bake.bakeAll( + messages: coordinator.messages, + writer: writer, + envInputs: envInputs, + senderTables: senderTablesProvider(), + postApply: postApply + ) + return olderMessages + } catch is CancellationError { + writer.updateRenderState { $0.with(isLoadingOlder: false) } + return [] + } catch { + writer.updateRenderState { $0.with(isLoadingOlder: false) } + throw error + } } - - olderMessages = bake.filterOutgoingReactionMessages(olderMessages, isDM: isDM) - - // An in-flight admission can land a message this fetch also carries; - // drop rows already present so the prepend cannot duplicate them. - let existingIDs = Set(messages.map(\.id)) - olderMessages = olderMessages.filter { !existingIDs.contains($0.id) } - - // Prepend older messages (they're chronologically earlier), then - // re-run same-sender reordering across the page boundary to handle - // clusters that were split between the existing and newly loaded pages. - writer.prepend(olderMessages) - let reordered = MessageDTO.reorderSameSenderClusters(messages) - writer.replaceMessagesPreservingByID(reordered) - - // Clear the spinner before rebaking, not after. `updateRenderState` - // bumps the coordinator's `renderStateID`; doing it after the rebake - // invalidates the just-launched off-main build on apply, forcing a full - // duplicate rebuild of the entire timeline. The prepended messages are - // already in the canonical array, so the spinner can retire now, and - // slower caller-side follow-up (reaction indexing) never gates it. - writer.updateRenderState { $0.with(isLoadingOlder: false) } - - rebakeAll() - return olderMessages + } catch is CancellationError { + return [] } catch { - writer.updateRenderState { $0.with(isLoadingOlder: false) } throw error } } diff --git a/MC1/Views/Chats/Timeline/ChatTimeline.swift b/MC1/Views/Chats/Timeline/ChatTimeline.swift index ce6e4f79c..a454c59f7 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline.swift @@ -66,7 +66,22 @@ final class ChatTimeline { /// `stageOpen` and `open`. var initialLoadSettled = false - /// Live per-connection store; nil while disconnected (offline browse). + #if DEBUG + /// When set, `open` returns populate's failure contract without a process-global hook. + var testPopulateError: Error? + + /// Awaited in `loadOlder` after the fetch so a test can queue populate behind it. + @ObservationIgnored + var loadOlderInterleaveHook: (@MainActor () async -> Void)? + + /// When set, `loadOlder` throws this after the interleave hook. + var loadOlderTestError: Error? + + /// When set, populate throws this after the entry spinner clear. + var testPopulateFetchError: Error? + #endif + + /// Process-lifetime store when a radio has been paired. Nil until first pair. @ObservationIgnored var dataStoreProvider: @MainActor () -> DataStore? = { nil } diff --git a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift index 973222c22..2fe4894c3 100644 --- a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift @@ -244,19 +244,8 @@ extension ChatMessageBakeState { messages.filter { !isHiddenOutgoingReaction($0, isDM: isDM) } } - /// 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 } - - let isReaction = isDM - ? ReactionParser.parseDM(message.text) != nil - : ReactionParser.parse(message.text) != nil - - guard isReaction else { return false } - - return message.status != .failed + message.isHiddenOutgoingReaction(isDM: isDM) } // MARK: - Batch Bake diff --git a/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift b/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift index b693e8671..7bc933745 100644 --- a/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift +++ b/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift @@ -23,9 +23,9 @@ enum ChatTimelinePopulator { let rebakeRow: @MainActor (UUID) -> Void } - /// Populates `writer`'s coordinator with the first page for `conversation` - /// and bakes render items. Owns the loading bracket (`beginLoading` / - /// `markLoaded` on every exit). + /// Populates `writer`'s coordinator and bakes render items. Owns the + /// loading bracket. `anchorSortDate` is nil for a first-page open; a + /// refresh passes the oldest loaded `sortDate` so paged-in history stays. static func populate( _ conversation: ChatConversationType, writer: ChatTimelineWriter, @@ -33,8 +33,8 @@ enum ChatTimelinePopulator { bake: ChatMessageBakeState, envInputs: EnvInputs, senderTables: ChatSenderTables, - reactions: ReactionIndexingContext?, - postApply: (@MainActor () -> Void)? + postApply: (@MainActor () -> Void)?, + anchorSortDate: Date? ) async -> Outcome { writer.beginLoading() @@ -43,42 +43,58 @@ enum ChatTimelinePopulator { return .unavailable } - // Reset pagination state for the new conversation page. - writer.updateRenderState { $0.with(hasMoreMessages: true, isLoadingOlder: false, totalFetchedCount: 0) } + // Clear a stuck prepend spinner from a loadOlder that raced a disconnect. + // Leave the loaded window intact: the next refresh reads `min(sortDate)` from it. + writer.updateRenderState { $0.with(isLoadingOlder: false) } do { + #if DEBUG + if let error = writer.testPopulateFetchError { + throw error + } + #endif let unreadCount = await currentUnreadCount(for: conversation, dataStore: dataStore) let isDM: Bool - let initialLimit = ChatCoordinator.initialPageSize(unreadCount: unreadCount) - var fetchedMessages: [MessageDTO] + let floorLimit = ChatCoordinator.initialPageSize(unreadCount: unreadCount) + let window: (messages: [MessageDTO], hasMore: Bool) switch conversation { case let .dm(contact): isDM = true - fetchedMessages = try await dataStore.fetchMessages( + window = try await dataStore.fetchMessageWindow( contactID: contact.id, - limit: initialLimit, - offset: 0 + anchorSortDate: anchorSortDate, + floorLimit: floorLimit ) case let .channel(channel): isDM = false - fetchedMessages = try await dataStore.fetchMessages( + window = try await dataStore.fetchMessageWindow( radioID: channel.radioID, channelIndex: channel.index, - limit: initialLimit, - offset: 0 + anchorSortDate: anchorSortDate, + floorLimit: floorLimit ) } + #if DEBUG + await writer.testPopulateAfterFetchHook?() + #endif + try Task.checkCancellation() + + var fetchedMessages = window.messages let unfilteredCount = fetchedMessages.count - writer.updateRenderState { $0.with(totalFetchedCount: unfilteredCount) } // Divider from the unfiltered fetch so a hidden outgoing reaction at // the boundary still places the line at the correct visual index. bake.computeDividerPosition(from: fetchedMessages, unreadCount: unreadCount, isDM: isDM) fetchedMessages = bake.filterOutgoingReactionMessages(fetchedMessages, isDM: isDM) - writer.updateRenderState { $0.with(hasMoreMessages: unfilteredCount == initialLimit) } + writer.updateRenderState { + $0.with( + hasMoreMessages: window.hasMore, + totalFetchedCount: unfilteredCount + ) + } writer.replaceAll(fetchedMessages) bake.bakeAll( @@ -89,17 +105,6 @@ enum ChatTimelinePopulator { postApply: postApply ) - if let reactions { - await indexMessagesForReactions( - fetchedMessages, - scope: reactions.scope, - reactionService: reactions.reactionService, - dataStore: dataStore, - writer: writer, - rebakeRow: reactions.rebakeRow - ) - } - writer.markLoaded() return .loaded } catch is CancellationError { diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift index 72e68cf4f..eff6443fa 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift @@ -8,14 +8,15 @@ extension ChatViewModel { /// populates the coordinator, then clears unread state. Delegates coordinator /// population to `primeInitialChannelMessages(for:)`; the unread/badge/notify /// side effects here run only when that load succeeded. - func loadChannelMessages(for channel: ChannelDTO) async { + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. + func loadChannelMessages(for channel: ChannelDTO, populateMode: ChatPopulateMode) async { // Track active channel for notification suppression notificationService?.setActiveConversation( channelIndex: channel.index, channelRadioID: channel.radioID ) - let loaded = await primeInitialChannelMessages(for: channel) + let loaded = await primeInitialChannelMessages(for: channel, populateMode: populateMode) // Push the device flood scope after populating the timeline. A device // command, so it runs only on real channel open — never during prefetch. @@ -38,15 +39,15 @@ extension ChatViewModel { await notificationService?.updateBadgeCount() } - /// Populates the bound coordinator with the first page for `channel` and builds - /// its render items and mention senders — with no notification, flood-scope, + /// Populates the bound coordinator for `channel` and builds its render + /// items and mention senders — with no notification, flood-scope, /// unread-clearing, or badge side effects. Safe to run before navigation to /// warm the coordinator so the channel renders populated on the first frame /// instead of popping in after the push transition. `loadChannelMessages` - /// layers the open-time side effects on top. Returns true when the fetch - /// succeeded. + /// layers the open-time side effects on top. + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. @discardableResult - func primeInitialChannelMessages(for channel: ChannelDTO) async -> Bool { + func primeInitialChannelMessages(for channel: ChannelDTO, populateMode: ChatPopulateMode) async -> Bool { // Clear preview state only when switching away from a previously loaded // conversation. A fresh view model has nothing to clear, and its cells // may already be fetching previews for this same conversation (warm @@ -76,7 +77,11 @@ extension ChatViewModel { ) } - let outcome = await timeline.open(.channel(channel), reactions: reactions) + let outcome = await timeline.open( + .channel(channel), + reactions: reactions, + populateMode: populateMode + ) let didLoad: Bool switch outcome { diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift index 23e0e0af6..e1973d556 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift @@ -23,11 +23,12 @@ extension ChatViewModel { /// coordinator, then clears unread state. Delegates the coordinator population /// to `primeInitialMessages(for:)`; the unread/badge/notify side effects here /// run only when that load succeeded. - func loadMessages(for contact: ContactDTO) async { + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. + func loadMessages(for contact: ContactDTO, populateMode: ChatPopulateMode) async { // Track active conversation for notification suppression notificationService?.setActiveConversation(contactID: contact.id) - guard await primeInitialMessages(for: contact) else { return } + guard await primeInitialMessages(for: contact, populateMode: populateMode) else { return } // Clear unread count and mention badge, then notify UI to refresh chat list. // The messages already rendered, so a bookkeeping failure here is logged @@ -44,14 +45,15 @@ extension ChatViewModel { await notificationService?.updateBadgeCount() } - /// Populates the bound coordinator with the first page for `contact` and builds - /// its render items — with no notification, unread-clearing, or badge side - /// effects. Safe to run before navigation to warm the coordinator so the + /// Populates the bound coordinator for `contact` and builds its render + /// items — with no notification, unread-clearing, or badge side effects. + /// Safe to run before navigation to warm the coordinator so the /// conversation renders populated on the first frame instead of popping in a /// frame after the push transition. `loadMessages` layers the open-time side - /// effects on top. Returns true when the fetch succeeded. + /// effects on top. + /// `populateMode` selects first-page replacement or an in-place window refresh; see `ChatPopulateMode`. @discardableResult - func primeInitialMessages(for contact: ContactDTO) async -> Bool { + func primeInitialMessages(for contact: ContactDTO, populateMode: ChatPopulateMode) async -> Bool { // Clear preview state only when switching away from a previously loaded // conversation. A fresh view model has nothing to clear, and its cells // may already be fetching previews for this same conversation (warm @@ -77,7 +79,11 @@ extension ChatViewModel { ChatTimeline.ReactionIndexing(service: $0, scope: .direct(contact)) } - let outcome = await timeline.open(.dm(contact), reactions: reactions) + let outcome = await timeline.open( + .dm(contact), + reactions: reactions, + populateMode: populateMode + ) let didLoad: Bool switch outcome { diff --git a/MC1/Views/Settings/AppBackupViewModel.swift b/MC1/Views/Settings/AppBackupViewModel.swift index e91768ec9..bdb09dcbb 100644 --- a/MC1/Views/Settings/AppBackupViewModel.swift +++ b/MC1/Views/Settings/AppBackupViewModel.swift @@ -230,13 +230,13 @@ final class AppBackupViewModel { 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. + // Import runs in one store-actor turn. Per-operation saves leave no + // foreign pending writes, so rollback can only discard this import. guard !connectionManager.connectionState.isConnected else { dismissImportSheet() return } - let store = connectionManager.createStandalonePersistenceStore() + let store = preferredPersistenceStore() isCancellingImport = false importState = .importing @@ -314,9 +314,7 @@ final class AppBackupViewModel { } 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() + connectionManager.persistenceStore } private func isUserCancelled(_ error: any Error) -> Bool { diff --git a/MC1/Views/Settings/Sections/DiagnosticsSection.swift b/MC1/Views/Settings/Sections/DiagnosticsSection.swift index e2643fba0..b2d47ca36 100644 --- a/MC1/Views/Settings/Sections/DiagnosticsSection.swift +++ b/MC1/Views/Settings/Sections/DiagnosticsSection.swift @@ -49,7 +49,7 @@ struct DiagnosticsSection: View { } private func exportLogs() { - let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + let dataStore = appState.connectionManager.persistenceStore isExporting = true Task { @MainActor in @@ -66,7 +66,7 @@ struct DiagnosticsSection: View { } private func clearDebugLogs() { - let dataStore = appState.services?.dataStore ?? appState.connectionManager.createStandalonePersistenceStore() + let dataStore = appState.connectionManager.persistenceStore Task { do { diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift index e058ca633..db7b80e54 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Lifecycle.swift @@ -125,12 +125,11 @@ public extension ConnectionManager { """) // Reset stale room session connections from previous app launch - let resetStore = createStandalonePersistenceStore() - try? await resetStore.resetAllRemoteNodeSessionConnections() + try? await persistenceStore.resetAllRemoteNodeSessionConnections() // Populate radioID on existing devices and backfill deduplication keys (one-time migration) do { - try await resetStore.performRadioIDMigration() + try await persistenceStore.performRadioIDMigration() } catch { logger.error("radioID migration failed: \(error)") } @@ -138,7 +137,7 @@ public extension ConnectionManager { // 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() + try await persistenceStore.performChannelFloodScopeMigration() } catch { logger.error("channel flood-scope migration failed: \(error)") } @@ -146,7 +145,7 @@ public extension ConnectionManager { // Zero accumulated unread counts on repeater-type contacts and repeater-role // sessions so the badge stops including invisible records. do { - try await resetStore.performRepeaterUnreadCountMigration() + try await persistenceStore.performRepeaterUnreadCountMigration() } catch { logger.error("repeater unread-count migration failed: \(error)") } @@ -154,7 +153,7 @@ public extension ConnectionManager { // Backfill sortDate from createdAt on pre-existing messages so date-header // grouping keeps their current display order. do { - try await resetStore.performSortDateBackfillMigration() + try await persistenceStore.performSortDateBackfillMigration() } catch { logger.error("sortDate backfill migration failed: \(error)") } @@ -164,7 +163,7 @@ public extension ConnectionManager { // 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() + try await persistenceStore.performSortDateResetMigration() } catch { logger.error("sortDate reset migration failed: \(error)") } @@ -213,7 +212,7 @@ public extension ConnectionManager { connectionIntent = .wantsConnection() // Check if last device was WiFi - try WiFi first - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore if let device = try? await dataStore.fetchDevice(id: lastDeviceID), let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { if case let .wifi(host, port, _) = wifiMethod { @@ -678,7 +677,7 @@ public extension ConnectionManager { // Create services let newServices = ServiceContainer( session: session, - modelContainer: modelContainer, + dataStore: persistenceStore, radioID: MockDataProvider.simulatorDeviceID, appStateProvider: appStateProvider, connectionStateEvents: connectionStateEvents, diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift index 108d8077f..016e6a787 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+Pairing.swift @@ -114,7 +114,7 @@ public extension ConnectionManager { if let connectedID = connectedDevice?.id { protectedIDs.insert(connectedID) } if let attemptID = activeConnectionAttemptDeviceID { protectedIDs.insert(attemptID) } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore for info in pairing.registeredDeviceInfos() where !protectedIDs.contains(info.id) { let existingDevice: DeviceDTO? @@ -152,7 +152,7 @@ public extension ConnectionManager { logger.warning("Failed to remove from pairing registry: \(error.localizedDescription)") } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore try? await dataStore.demoteDeviceToGhost(id: deviceID) // Always clear this device's bond verification; store keys are holder-matched. @@ -221,7 +221,7 @@ public extension ConnectionManager { await disconnect(reason: .forgetDevice) try await pairing.removeDevice(deviceID) - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore do { if deleteData { try await dataStore.deleteDeviceAndData(id: deviceID) @@ -250,7 +250,7 @@ public extension ConnectionManager { logger.warning("Failed to remove device from pairing registry: \(error.localizedDescription)") } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore do { try await dataStore.deleteDeviceAndData(id: id) } catch { @@ -272,7 +272,7 @@ public extension ConnectionManager { throw ConnectionError.notConnected } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore let allContacts = try await dataStore.fetchContacts(radioID: radioID) return allContacts.count(where: { !$0.isFavorite }) } @@ -314,7 +314,7 @@ public extension ConnectionManager { throw ConnectionError.notConnected } - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore let allContacts = try await dataStore.fetchContacts(radioID: radioID) // Never bulk-remove the ZephCore V-contact: CMD_REMOVE turns firmware v.contact off. let selfPublicKey = connectedDevice?.publicKey @@ -531,7 +531,7 @@ public extension ConnectionManager { /// 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 dataStore = persistenceStore let devices = try await dataStore.fetchDevices() logger.info("fetchSavedDevices returning \(devices.count) devices") return devices @@ -542,7 +542,7 @@ public extension ConnectionManager { /// - 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) + let dataStore = persistenceStore try await dataStore.demoteDeviceToGhost(id: id) // Always clear this device's bond verification; store keys are holder-matched @@ -595,7 +595,7 @@ extension ConnectionManager: DevicePairingDelegate { } // Demote to ghost — preserve publicKey ↔ radioID bridge - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore do { try await dataStore.demoteDeviceToGhost(id: bluetoothID) } catch { @@ -621,7 +621,7 @@ extension ConnectionManager: DevicePairingDelegate { } // Delete device record only — no data exists for a failed pairing - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore do { try await dataStore.deleteDevice(id: bluetoothID) logger.info("Deleted device record after failed pairing") diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift index e27bafbc7..a45295399 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift @@ -287,7 +287,7 @@ extension ConnectionManager { if connectionState == .disconnected, connectionIntent.wantsConnection, let lastDeviceID = lastConnectedDeviceID { - let dataStore = PersistenceStore(modelContainer: modelContainer) + let dataStore = persistenceStore if let device = try? await dataStore.fetchDevice(id: lastDeviceID), let wifiMethod = device.connectionMethods.first(where: { $0.isWiFi }) { if case let .wifi(host, port, _) = wifiMethod { diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift index e2fb3da92..c1c372d99 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager.swift @@ -205,6 +205,10 @@ public final class ConnectionManager { /// Installed by `AppState` during initialization. public var onDeviceSynced: (() async -> Void)? + /// Called when `clearPersistedConnection(for:)` clears the last-connected slot. + /// Installed by `AppState` during initialization. + public var onLastConnectedDeviceCleared: (@MainActor @Sendable () -> Void)? + /// Provider for app foreground/background state detection. /// Installed by `AppState` during initialization. public var appStateProvider: AppStateProvider? @@ -230,10 +234,9 @@ public final class ConnectionManager { pairing.hasSystemPairingRegistry } - /// Creates a standalone persistence store for operations that don't require services - public func createStandalonePersistenceStore() -> PersistenceStore { - PersistenceStore(modelContainer: modelContainer) - } + /// Process-lifetime persistence actor, created once in `init`. Session + /// services receive this instance; no production path mints a second one. + public let persistenceStore: PersistenceStore // MARK: - Internal Components @@ -438,6 +441,10 @@ public final class ConnectionManager { /// Test override for lastConnectedDeviceID var testLastConnectedDeviceID: UUID? + /// When true, `lastConnectedDeviceID` is nil so never-paired tests can + /// drive `offlineDataStore` without touching the host UserDefaults. + var testForceNeverPaired = false + /// When set, the first watchdog sleep uses this instead of 30s so natural-exit /// tests can complete without waiting on production backoff. var testWatchdogInitialDelay: Duration? @@ -466,6 +473,7 @@ public final class ConnectionManager { /// The last connected device ID (for auto-reconnect) public var lastConnectedDeviceID: UUID? { #if DEBUG + if testForceNeverPaired { return nil } if let testID = testLastConnectedDeviceID { return testID } @@ -509,11 +517,15 @@ public final class ConnectionManager { /// already observed a true `shouldPersistBondRefresh` still no-ops. func clearPersistedConnection(for deviceID: UUID) async { bondRefreshPersistEpoch &+= 1 + let wasLastConnected = lastConnectionStore.deviceID == deviceID await stateMachine.clearBondVerification(deviceID: deviceID) if await stateMachine.isAppSessionLive(deviceID: deviceID) { await stateMachine.setAppSessionLive(deviceID: nil) } lastConnectionStore.clear(for: deviceID) + if wasLastConnected { + onLastConnectedDeviceCleared?() + } } /// Persist path for RSSI bond refresh: snapshot epoch, re-validate on the SM, @@ -587,6 +599,7 @@ public final class ConnectionManager { pairing: (any DevicePairingService)? = nil ) { self.modelContainer = modelContainer + persistenceStore = PersistenceStore(modelContainer: modelContainer) self.defaults = defaults lastConnectionStore = LastConnectionStore(defaults: defaults) connectionIntent = .restored(from: defaults) @@ -851,10 +864,9 @@ public final class ConnectionManager { // 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 existingDevice = try? await persistenceStore.fetchDevice(id: deviceID) let deviceByPublicKey: DeviceDTO? = if existingDevice == nil { - try? await standaloneStore.fetchDevice(publicKey: selfInfo.publicKey) + try? await persistenceStore.fetchDevice(publicKey: selfInfo.publicKey) } else { nil } @@ -863,7 +875,7 @@ public final class ConnectionManager { let newServices = ServiceContainer( session: session, - modelContainer: modelContainer, + dataStore: persistenceStore, radioID: resolvedRadioID, appStateProvider: appStateProvider, connectionStateEvents: connectionStateEvents, diff --git a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift index 79dbbf404..7c15a0772 100644 --- a/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift +++ b/MC1Services/Sources/MC1Services/Protocols/Persistence/MessagePersisting.swift @@ -29,6 +29,23 @@ public protocol MessagePersisting: Actor { /// Fetch messages for a channel func fetchMessages(radioID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] + /// Fetch the newest window for a contact: at least `floorLimit` rows, + /// widened to every row with `sortDate` at or newer than `anchorSortDate` + /// (nil means the floor alone). `hasMore` is whether older rows remain. + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) + + /// Fetch the newest window for a channel; see the contact variant. + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) + /// 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]] diff --git a/MC1Services/Sources/MC1Services/ServiceContainer.swift b/MC1Services/Sources/MC1Services/ServiceContainer.swift index 54d5d02a6..8a2f7599a 100644 --- a/MC1Services/Sources/MC1Services/ServiceContainer.swift +++ b/MC1Services/Sources/MC1Services/ServiceContainer.swift @@ -13,9 +13,11 @@ import SwiftData /// The container is per-connection, not a singleton: `ConnectionManager` builds a /// fresh `ServiceContainer` (and a fresh session) on every connection in /// `buildServicesAndSaveDevice`, and tears it down via `tearDown()` on disconnect -/// before nilling its reference. Anything that must survive reconnects (for example -/// detected platform or last-clean-sync state) lives on `ConnectionManager`, not here. -/// `init` also reassigns the `DebugLogBuffer.shared` global to this container's buffer, +/// before nilling its reference. The `PersistenceStore` is injected and +/// process-lifetime on `ConnectionManager`; it is not minted here. Anything +/// else that must survive reconnects (for example detected platform or +/// last-clean-sync state) lives on `ConnectionManager`, not here. `init` also +/// reassigns the `DebugLogBuffer.shared` global to this container's buffer, /// so a stale container's services must not keep running past teardown. /// /// ## Usage @@ -24,7 +26,7 @@ import SwiftData /// // Create container with session and model container /// let container = ServiceContainer( /// session: meshCoreSession, -/// modelContainer: modelContainer, +/// dataStore: persistenceStore, /// radioID: radioUUID /// ) /// @@ -204,7 +206,8 @@ public final class ServiceContainer { /// /// - Parameters: /// - session: The MeshCoreSession for device communication - /// - modelContainer: The SwiftData model container for persistence + /// - dataStore: Process-lifetime persistence store. Injected by + /// `ConnectionManager`; the container does not mint its own. /// - 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. @@ -218,7 +221,7 @@ public final class ServiceContainer { /// already-connected initial value as a fired edge. init( session: MeshCoreSession, - modelContainer: ModelContainer, + dataStore: PersistenceStore, radioID: UUID, appStateProvider: AppStateProvider? = nil, connectionStateEvents: EventBroadcaster? = nil, @@ -226,7 +229,7 @@ public final class ServiceContainer { ) { self.session = session self.appStateProvider = appStateProvider - dataStore = PersistenceStore(modelContainer: modelContainer) + self.dataStore = dataStore inlineImageDimensionsStore = InlineImageDimensionsStore() // Independent services (no dependencies) @@ -408,9 +411,8 @@ public final class ServiceContainer { } /// 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. + /// so the chat send queue drains. `stopEventMonitoring()` alone does not + /// cover that. func tearDown() async { await stopEventMonitoring() @@ -480,9 +482,10 @@ extension ServiceContainer { radioID: UUID = UUID() ) async throws -> ServiceContainer { let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) return ServiceContainer( session: session, - modelContainer: container, + dataStore: store, radioID: radioID ) } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift index 5d4e0617f..91f308c41 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Reload.swift @@ -80,22 +80,8 @@ extension ChatCoordinator { } } - /// 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. + /// Drop all state and re-fetch the loaded window. Runs on the window + /// lane so it cannot interleave with populate or loadOlder. func hardReset(reason: String) { logger.warning("ChatCoordinator hardReset: \(reason, privacy: .public)") hardResetInFlight = true @@ -103,49 +89,65 @@ extension ChatCoordinator { 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 { + if let self, !Task.isCancelled, !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 - ) + await self?.performWindowOperation { + do { + try Task.checkCancellation() + guard let self else { return } + let anchorSortDate = self.messages.map(\.sortDate).min() + let window: (messages: [MessageDTO], hasMore: Bool) = switch id.conversation { + case let .dm(contactID): + try await dataStore.fetchMessageWindow( + contactID: contactID, + anchorSortDate: anchorSortDate, + floorLimit: Self.pageSize + ) + case let .channel(channelIndex): + try await dataStore.fetchMessageWindow( + radioID: id.radioID, + channelIndex: channelIndex, + anchorSortDate: anchorSortDate, + floorLimit: Self.pageSize + ) + } + #if DEBUG + await self.hardResetAfterFetchHook?() + #endif + try Task.checkCancellation() + let unfilteredCount = window.messages.count + self.replaceAll(self.hidingOutgoingReactions(window.messages)) + self.updateRenderState { + $0.with( + hasMoreMessages: window.hasMore, + totalFetchedCount: unfilteredCount + ) + } + self.renderStateInvalidated?() + } catch is CancellationError { + } catch { + self?.logger.error("hardReset refetch failed: \(String(describing: error))") } - 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. + /// Cancel in-flight maintenance Tasks. Called from + /// `ChatCoordinatorRegistry` before it drops this coordinator. func cancelInFlight() { buildItemsTask?.cancel() coalescedReloadTask?.cancel() hardResetTask?.cancel() } + + private func hidingOutgoingReactions(_ messages: [MessageDTO]) -> [MessageDTO] { + let isDM = switch conversationID.conversation { + case .dm: true + case .channel: false + } + return messages.filter { !$0.isHiddenOutgoingReaction(isDM: isDM) } + } } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift index e5e7b0c0a..1a02ca017 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift @@ -20,15 +20,13 @@ public enum ChatWriterRole: String, Sendable { /// sheet dismissal, navigation transitions — share one `ChatCoordinator`; /// the registry resolves instances by `ChatConversationID`. /// -/// Owned by `ChatCoordinatorRegistry` on `ServiceContainer`. Lives for the -/// lifetime of the `ServiceContainer` (i.e., the lifetime of a single -/// connection). Tears down on disconnect. +/// Owned by `ChatCoordinatorRegistry` on `AppState`. Lifetime is bounded +/// by LRU eviction and backup restore, not by the connection. @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. + /// Messages fetched per pagination page. `hardReset` uses this as the + /// window-refetch floor; `ChatViewModel` uses it for initial-load sizing. public static let pageSize: Int = 50 /// Read messages loaded above the first unread so the "New Messages" divider @@ -114,11 +112,8 @@ public final class ChatCoordinator { @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. + /// In-flight coalesced-reload drain Task. Stored so `cancelInFlight` + /// can stop it. Distinct from `reloadInFlight`, which breaks the running loop. @ObservationIgnored public internal(set) var coalescedReloadTask: Task? @@ -127,6 +122,25 @@ public final class ChatCoordinator { @ObservationIgnored public internal(set) var hardResetTask: Task? + /// Completion marker for the latest window operation. `performWindowOperation` + /// chains on it so populate, loadOlder, and hardReset never interleave. + @ObservationIgnored + var windowOperationTask: Task? + + /// Runs `operation` after prior window operations finish, in the caller's + /// task so cancellation and errors propagate. `hardReset` mutates the + /// coordinator directly; populate and loadOlder still use a writer. + func performWindowOperation( + _ operation: @MainActor () async throws -> T + ) async rethrows -> T { + let prior = windowOperationTask + let (turnEnded, turn) = AsyncStream.makeStream() + windowOperationTask = Task { for await _ in turnEnded {} } + defer { turn.finish() } + await prior?.value + return try await operation() + } + /// Data store used by `applyReloadedIDs` for per-ID fetches. Bound at /// construction by the registry. `@ObservationIgnored` — never read /// from a view body. @@ -252,5 +266,14 @@ public final class ChatCoordinator { public func markLoadedForTesting() { markLoaded() } + + /// When set, populate throws this after the entry spinner clear. + public var testPopulateFetchError: Error? + + /// Awaited in populate after the window fetch so a test can cancel before commit. + public var testPopulateAfterFetchHook: (@MainActor () async -> Void)? + + /// Awaited in `hardReset` after the window fetch so a test can cancel before `replaceAll`. + public var hardResetAfterFetchHook: (@MainActor () async -> Void)? #endif } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift index 92e640d97..9c8cb796a 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinatorRegistry.swift @@ -1,14 +1,14 @@ import Foundation /// Owns `ChatCoordinator` instances keyed by `ChatConversationID`. -/// Owned by `AppState`; tears down on disconnect/radio-switch and rebinds -/// its dataStore when services arrive. Multiple consumers resolving the -/// same `ChatConversationID` share one `ChatCoordinator`, so canonical -/// chat state stays unified across views. +/// Owned by `AppState` and outlives connections. Multiple consumers +/// resolving the same `ChatConversationID` share one `ChatCoordinator`, +/// so canonical chat state stays unified across views. /// /// Capped by an LRU policy (default 16 entries) so the steady-state memory /// footprint stays bounded even on long sessions across many conversations. -/// Evicted coordinators have their in-flight builds cancelled. +/// Evicted coordinators have their in-flight builds cancelled. `clear()` +/// empties entries; the registry stays and later lookups mint fresh ones. /// /// Intentionally not `@Observable` — views resolve one coordinator and /// observe that coordinator's properties. The registry is a lookup table; @@ -19,7 +19,7 @@ public final class ChatCoordinatorRegistry { private var entries: [(id: ChatConversationID, coordinator: ChatCoordinator)] = [] private let capacity: Int - private(set) var dataStore: PersistenceStore + private let dataStore: PersistenceStore public init( dataStore: PersistenceStore, @@ -58,14 +58,9 @@ public final class ChatCoordinatorRegistry { entries.first(where: { $0.id == id })?.coordinator } - 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() { + /// Cancel in-flight builds and drop all entries. The registry stays + /// usable; `coordinator(for:)` mints fresh empty entries. + public func clear() { for entry in entries { entry.coordinator.cancelInFlight() } diff --git a/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift b/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift index 58b6aa9b0..03827ec26 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatTimelineWriter.swift @@ -4,10 +4,11 @@ import Foundation /// /// Minted exclusively by `ChatCoordinator.bindWriter(owner:role:...)`; the /// coordinator's mutation methods are internal to MC1Services, so holding a -/// writer is the only way app code can mutate a timeline. Every forwarder -/// checks the mint generation against the coordinator's current one: once a -/// newer writer is bound, this writer's mutations no-op, so a stale prime -/// (or a superseded view model) can never write over the live conversation. +/// writer is the only way app code can mutate a timeline. Every mutation +/// forwarder checks the mint generation against the coordinator's current +/// one: once a newer writer is bound, this writer's mutations no-op, so a +/// stale prime (or a superseded view model) can never write over the live +/// conversation. The window-lane enqueue is not gated. /// /// Reads are not gated; consumers keep reading `coordinator.renderState`, /// `messages`, and `messagesByID` directly. @@ -31,6 +32,25 @@ public final class ChatTimelineWriter { generation == coordinator.writerGeneration } + #if DEBUG + /// Lets populate throw after the entry spinner clear without a production parameter. + public var testPopulateFetchError: Error? { + coordinator.testPopulateFetchError + } + + public var testPopulateAfterFetchHook: (@MainActor () async -> Void)? { + coordinator.testPopulateAfterFetchHook + } + #endif + + /// Runs `operation` on the coordinator's serialized window lane. + /// No staleness gate at enqueue: mutations inside are already generation-gated. + public func performWindowOperation( + _ operation: @MainActor () async throws -> T + ) async rethrows -> T { + try await coordinator.performWindowOperation(operation) + } + /// Runs `body` only while this writer is current. A dropped `.prime` /// write is expected teardown noise; a dropped `.interactive` write /// almost always means a missed rebind (BFU scene rebuild, role diff --git a/MC1Services/Sources/MC1Services/Services/MessageDTO+ReactionVisibility.swift b/MC1Services/Sources/MC1Services/Services/MessageDTO+ReactionVisibility.swift new file mode 100644 index 000000000..380bd15bc --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/MessageDTO+ReactionVisibility.swift @@ -0,0 +1,14 @@ +import Foundation + +public extension MessageDTO { + /// A sent outgoing reaction renders as a badge, so the timeline hides + /// the row. Failed reactions stay visible so the user can retry them. + func isHiddenOutgoingReaction(isDM: Bool) -> Bool { + guard direction == .outgoing else { return false } + let isReaction = isDM + ? ReactionParser.parseDM(text) != nil + : ReactionParser.parse(text) != nil + guard isReaction else { return false } + return status != .failed + } +} diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift index 09dd49bd6..b0b9b931a 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+BackupImport.swift @@ -124,6 +124,9 @@ public extension PersistenceStore { let originalAutosaveEnabled = modelContext.autosaveEnabled var didCommit = false + // Flush pending changes so a failure rollback is scoped to this import. + if modelContext.hasChanges { try modelContext.save() } + modelContext.autosaveEnabled = false defer { diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift index 6ba06fec0..0c5f38771 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift @@ -108,6 +108,96 @@ public extension PersistenceStore { return MessageDTO.reorderSameSenderClusters(dtos) } + /// Fetching one row beyond the limit distinguishes "exactly limit rows + /// exist" from "more remain". + private static let hasMoreProbeCount = 1 + + /// Fetch the newest window for a contact: at least `floorLimit` rows, + /// widened to every row with `sortDate` at or newer than `anchorSortDate` + /// (nil means the floor alone). `hasMore` is whether older rows remain. + /// + /// A hidden reaction below the anchor falls out and shifts + /// `totalFetchedCount` so the next `loadOlder` offset still points at it. + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) throws -> (messages: [MessageDTO], hasMore: Bool) { + let targetContactID: UUID? = contactID + let limit = try windowLimit( + floorLimit: floorLimit, + anchorSortDate: anchorSortDate + ) { anchor in + #Predicate { message in + message.contactID == targetContactID && message.sortDate >= anchor + } + } + let predicate = #Predicate { message in + message.contactID == targetContactID + } + return try fetchMessageWindow(predicate: predicate, limit: limit) + } + + /// Fetch the newest window for a channel; see the contact variant. + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) throws -> (messages: [MessageDTO], hasMore: Bool) { + let targetRadioID = radioID + let targetChannelIndex: UInt8? = channelIndex + let limit = try windowLimit( + floorLimit: floorLimit, + anchorSortDate: anchorSortDate + ) { anchor in + #Predicate { message in + message.radioID == targetRadioID + && message.channelIndex == targetChannelIndex + && message.sortDate >= anchor + } + } + let predicate = #Predicate { message in + message.radioID == targetRadioID && message.channelIndex == targetChannelIndex + } + return try fetchMessageWindow(predicate: predicate, limit: limit) + } + + private func windowLimit( + floorLimit: Int, + anchorSortDate: Date?, + countPredicate: (Date) -> Predicate + ) throws -> Int { + guard let anchorSortDate else { return floorLimit } + let count = try modelContext.fetchCount( + FetchDescriptor(predicate: countPredicate(anchorSortDate)) + ) + return max(floorLimit, count) + } + + private func fetchMessageWindow( + predicate: Predicate, + limit: Int + ) throws -> (messages: [MessageDTO], hasMore: Bool) { + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.sortDate, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse), + SortDescriptor(\Message.createdAt, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + Self.hasMoreProbeCount + + var fetched = try modelContext.fetch(descriptor) + let hasMore = fetched.count > limit + if hasMore { + fetched.removeLast() + } + let dtos = fetched.reversed().map { MessageDTO(from: $0) } + return (MessageDTO.reorderSameSenderClusters(dtos), hasMore) + } + /// Finds a channel message matching a parsed reaction within a timestamp window. func findChannelMessageForReaction( radioID: UUID, diff --git a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift index d23ae5634..16fe64b9e 100644 --- a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift @@ -2471,66 +2471,61 @@ struct BackupIntegrationTests { } @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) + func `Import on the process store updates rows already registered in that context`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID) + let publicKey = Data(repeating: 0x51, count: 32) + let importedDate = Date(timeIntervalSince1970: 1_700_000_000) - let backupRadioID = UUID() - let liveRadioID = UUID() - let backupDevicePublicKey = Data(repeating: 0xB0, count: 32) - let liveDevicePublicKey = Data(repeating: 0xC0, count: 32) + try await store.saveContact( + ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + name: "Alice", + nickname: nil, + isBlocked: false, + unreadCount: 0 + ) + ) - let backupContact = ContactDTO.testContact( - radioID: backupRadioID, - publicKey: Data(repeating: 0xB1, count: 32), - name: "From backup" + // Warm registered objects the way a process-lifetime store does after + // live reads so later fetches see import-mutated fields. + let warmed = try #require( + await store.fetchContact(radioID: radioID, publicKey: publicKey) ) + #expect(warmed.nickname == nil) + #expect(warmed.isBlocked == false) + #expect(warmed.unreadCount == 0) + let envelope = AppBackupEnvelope.test( - devices: [ - DeviceDTO.testDevice( - id: backupRadioID, - radioID: backupRadioID, - publicKey: backupDevicePublicKey + devices: [DeviceDTO.testDevice(id: radioID, radioID: radioID)], + contacts: [ + ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey, + name: "Alice", + nickname: "Field Ops", + isBlocked: true, + lastMessageDate: importedDate, + unreadCount: 7 ) - ], - 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" + let result = try await AppBackupService().importBackup( + envelope: envelope, + into: store ) + #expect(result.contactsSkipped == 1) + #expect(result.contactsMerged == 1) - 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) + let after = try #require( + await store.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect(after.nickname == "Field Ops") + #expect(after.isBlocked == true) + #expect(after.unreadCount == 7) + #expect(after.lastMessageDate == importedDate) } // MARK: - Test 18: Export assigns content-based keys to nil-keyed messages diff --git a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift index 7c91d6ba6..94ee2236b 100644 --- a/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift +++ b/MC1Services/Tests/MC1ServicesTests/Mocks/MockPersistenceStore.swift @@ -123,6 +123,55 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { return Array(filtered.dropFirst(offset).prefix(limit)) } + public func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + try windowFrom( + messages.values.filter { $0.contactID == contactID }, + anchorSortDate: anchorSortDate, + floorLimit: floorLimit + ) + } + + public func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + try windowFrom( + messages.values.filter { $0.radioID == radioID && $0.channelIndex == channelIndex }, + anchorSortDate: anchorSortDate, + floorLimit: floorLimit + ) + } + + private func windowFrom( + _ candidates: some Sequence, + anchorSortDate: Date?, + floorLimit: Int + ) throws -> (messages: [MessageDTO], hasMore: Bool) { + if let error = stubbedFetchMessageError { + throw error + } + let newestFirst = candidates.sorted { + if $0.sortDate != $1.sortDate { return $0.sortDate > $1.sortDate } + if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } + return $0.createdAt > $1.createdAt + } + let limit: Int = if let anchorSortDate { + max(floorLimit, newestFirst.filter { $0.sortDate >= anchorSortDate }.count) + } else { + floorLimit + } + let fetched = Array(newestFirst.prefix(limit + 1)) + let hasMore = fetched.count > limit + let window = hasMore ? Array(fetched.dropLast()) : fetched + return (MessageDTO.reorderSameSenderClusters(Array(window.reversed())), hasMore) + } + public func findChannelMessageForReaction( radioID: UUID, channelIndex: UInt8, diff --git a/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift index d7a544258..94c89db8e 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingCancellationTests.swift @@ -20,7 +20,7 @@ struct PairingCancellationTests { // 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() + let store = manager.persistenceStore try await store.saveDevice(DeviceDTO.testDevice(id: deviceID)) manager.setTestState( @@ -75,7 +75,7 @@ struct PairingCancellationTests { // 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() + let store = manager.persistenceStore try await store.saveDevice(DeviceDTO.testDevice(id: deviceID)) manager.setTestState( diff --git a/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift b/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift index 1d61f45c7..10d013f95 100644 --- a/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PairingStrandedAssociationTests.swift @@ -23,7 +23,7 @@ struct PairingStrandedAssociationTests { // 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() + let store = manager.persistenceStore try await store.saveDevice(DeviceDTO.testDevice(id: deviceID)) mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: deviceID, displayName: "test")]) @@ -144,7 +144,7 @@ struct PairingStrandedAssociationTests { let mockASK = env.accessorySetupKit let savedID = UUID() - let store = manager.createStandalonePersistenceStore() + let store = manager.persistenceStore try await store.saveDevice(DeviceDTO.testDevice(id: savedID)) mockASK.setPairedAccessories([ASAccessory(bluetoothIdentifier: savedID, displayName: "saved")]) diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreMessageWindowTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreMessageWindowTests.swift new file mode 100644 index 000000000..203081e2a --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreMessageWindowTests.swift @@ -0,0 +1,142 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("PersistenceStore message window") +struct PersistenceStoreMessageWindowTests { + private func makeStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + @Test + func `nil anchor applies the floor and reports hasMore`() async throws { + let store = try await makeStore() + let contactID = UUID() + let radioID = UUID() + try await persistDirectMessages(store, radioID: radioID, contactID: contactID, timestamps: 1...10) + + let window = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: nil, + floorLimit: 3 + ) + #expect(window.messages.map(\.timestamp) == [8, 9, 10]) + #expect(window.hasMore) + } + + @Test + func `anchor widening beats the floor`() async throws { + let store = try await makeStore() + let contactID = UUID() + let radioID = UUID() + try await persistDirectMessages(store, radioID: radioID, contactID: contactID, timestamps: 1...10) + let anchor = Date(timeIntervalSince1970: 4) + + let window = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: anchor, + floorLimit: 3 + ) + #expect(window.messages.map(\.timestamp) == [4, 5, 6, 7, 8, 9, 10]) + #expect(window.hasMore) + } + + @Test + func `tie rows at the anchor are included`() async throws { + let store = try await makeStore() + let contactID = UUID() + let radioID = UUID() + let anchor = Date(timeIntervalSince1970: 5) + for timestamp in [3, 5, 5, 7] as [UInt32] { + try await store.saveMessage( + MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "m\(timestamp)", + timestamp: timestamp, + createdAt: timestamp == 5 ? anchor : Date(timeIntervalSince1970: TimeInterval(timestamp)) + ) + ) + } + + let window = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: anchor, + floorLimit: 1 + ) + #expect(window.messages.filter { $0.sortDate == anchor }.count == 2) + #expect(window.messages.map(\.timestamp).contains(3) == false) + #expect(window.hasMore) + } + + @Test + func `probe row is dropped and hasMore is exact at the boundary`() async throws { + let store = try await makeStore() + let contactID = UUID() + let radioID = UUID() + try await persistDirectMessages(store, radioID: radioID, contactID: contactID, timestamps: 1...4) + + let window = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: nil, + floorLimit: 3 + ) + #expect(window.messages.map(\.timestamp) == [2, 3, 4]) + #expect(window.hasMore) + + let exact = try await store.fetchMessageWindow( + contactID: contactID, + anchorSortDate: nil, + floorLimit: 4 + ) + #expect(exact.messages.map(\.timestamp) == [1, 2, 3, 4]) + #expect(exact.hasMore == false) + } + + @Test + func `channel window matches the contact variant`() async throws { + let store = try await makeStore() + let radioID = UUID() + let channelIndex: UInt8 = 2 + for timestamp in 1...5 as ClosedRange { + try await store.saveMessage( + MessageDTO.testChannelMessage( + radioID: radioID, + channelIndex: channelIndex, + text: "c\(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)) + ) + ) + } + + let window = try await store.fetchMessageWindow( + radioID: radioID, + channelIndex: channelIndex, + anchorSortDate: Date(timeIntervalSince1970: 3), + floorLimit: 2 + ) + #expect(window.messages.map(\.timestamp) == [3, 4, 5]) + #expect(window.hasMore) + } + + private func persistDirectMessages( + _ store: PersistenceStore, + radioID: UUID, + contactID: UUID, + timestamps: ClosedRange + ) async throws { + for timestamp in timestamps { + try await store.saveMessage( + MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "m\(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)) + ) + ) + } + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift index 25f0ae552..2545d5869 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryOfflineTests.swift @@ -27,29 +27,4 @@ struct ChatCoordinatorRegistryOfflineTests { #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 6b6f89d8b..c524ea6db 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorRegistryTests.swift @@ -35,23 +35,6 @@ struct ChatCoordinatorRegistryTests { #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) diff --git a/MC1Tests/AppState/ChatCoordinatorRegistrySurvivalTests.swift b/MC1Tests/AppState/ChatCoordinatorRegistrySurvivalTests.swift new file mode 100644 index 000000000..265d02e9b --- /dev/null +++ b/MC1Tests/AppState/ChatCoordinatorRegistrySurvivalTests.swift @@ -0,0 +1,239 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import MeshCore +import Testing + +/// The registry on `AppState` keeps the same coordinator across disconnect +/// and reconnect. `clear()` empties entries on restore and forget. +@MainActor +@Suite("Chat coordinator registry survival", .serialized) +struct ChatCoordinatorRegistrySurvivalTests { + @Test + func `disconnect then reconnect wiring keeps the same coordinator instance`() async throws { + let appState = AppState() + defer { appState.shutdown() } + appState.connectionManager.testLastConnectedDeviceID = UUID() + + let registry = try #require(appState.ensureChatCoordinatorRegistry()) + let conversationID = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) + let original = registry.coordinator(for: conversationID) + + appState.connectionManager.setTestState(services: .some(nil)) + await appState.wireServicesIfConnected() + + #expect(appState.chatCoordinatorRegistry === registry) + #expect(appState.chatCoordinatorRegistry?.existingCoordinator(for: conversationID) === original) + + let services = try await ServiceContainer.forTesting( + session: MeshCoreSession(transport: MockTransport()) + ) + appState.connectionManager.setTestState( + connectionState: .ready, + services: services + ) + await appState.wireServicesIfConnected() + + #expect(appState.chatCoordinatorRegistry === registry) + #expect(appState.chatCoordinatorRegistry?.existingCoordinator(for: conversationID) === original) + } + + @Test + func `notifyDataRestored bumps servicesVersion and the next load binds a fresh coordinator`() async throws { + let appState = AppState() + defer { appState.shutdown() } + appState.connectionManager.testLastConnectedDeviceID = UUID() + let store = try #require(appState.offlineDataStore) + + let radioID = UUID() + let contact = makeForgetTestContact(radioID: radioID) + try await store.saveContact(contact) + try await store.saveMessage(makeForgetTestMessage( + radioID: radioID, + contactID: contact.id, + timestamp: 1000 + )) + + let registry = try #require(appState.ensureChatCoordinatorRegistry()) + let viewModel = ChatViewModel() + viewModel.configure( + dependencies: appState.makeChatViewModelDependencies(), + onNavigateToMap: nil, + linkPreviewCache: nil, + chatCoordinatorRegistry: registry, + conversation: .dm(contact) + ) + viewModel.applyEnvInputs(.default) + #expect(await viewModel.primeInitialMessages(for: contact, populateMode: .replace)) + let original = try #require(viewModel.coordinator) + let versionBefore = appState.servicesVersion + + appState.notifyDataRestored() + + #expect(appState.servicesVersion == versionBefore + 1) + #expect(registry.existingCoordinator(for: original.conversationID) == nil) + + viewModel.configure( + dependencies: appState.makeChatViewModelDependencies(), + onNavigateToMap: nil, + linkPreviewCache: nil, + chatCoordinatorRegistry: registry, + conversation: .dm(contact) + ) + #expect(await viewModel.primeInitialMessages(for: contact, populateMode: .refreshWindow)) + let rebound = try #require(viewModel.coordinator) + #expect(rebound !== original) + } + + @Test + func `notifyDataRestored drops registry entries`() throws { + let appState = AppState() + defer { appState.shutdown() } + appState.connectionManager.testLastConnectedDeviceID = UUID() + + let registry = try #require(appState.ensureChatCoordinatorRegistry()) + let conversationID = ChatConversationID.dm(radioID: UUID(), contactID: UUID()) + let original = registry.coordinator(for: conversationID) + + appState.notifyDataRestored() + + #expect(appState.chatCoordinatorRegistry === registry) + #expect(registry.existingCoordinator(for: conversationID) == nil) + let afterRestore = registry.coordinator(for: conversationID) + #expect(afterRestore !== original) + } + + @Test + func `forgetting the last-connected device empties the registry and reloads unavailable`() async throws { + let suiteName = "test.forget-registry.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let container = try PersistenceStore.createContainer(inMemory: true) + let appState = AppState(modelContainer: container, defaults: defaults) + defer { appState.shutdown() } + let deviceID = UUID() + let radioID = UUID() + appState.connectionManager.persistConnection( + deviceID: deviceID, + radioID: radioID, + deviceName: "ForgetTest" + ) + + let store = try #require(appState.offlineDataStore) + let contact = makeForgetTestContact(radioID: radioID) + try await store.saveContact(contact) + try await store.saveMessage(makeForgetTestMessage( + radioID: radioID, + contactID: contact.id, + timestamp: 1000 + )) + + let registry = try #require(appState.ensureChatCoordinatorRegistry()) + let conversationID = ChatConversationID.dm(radioID: radioID, contactID: contact.id) + let viewModel = ChatViewModel() + viewModel.configure( + dependencies: appState.makeChatViewModelDependencies(), + onNavigateToMap: nil, + linkPreviewCache: nil, + chatCoordinatorRegistry: registry, + conversation: .dm(contact) + ) + viewModel.applyEnvInputs(.default) + #expect(await viewModel.primeInitialMessages(for: contact, populateMode: .replace)) + #expect(viewModel.messages.isEmpty == false) + + let versionBefore = appState.servicesVersion + await appState.connectionManager.clearPersistedConnection(for: deviceID) + + #expect(appState.chatCoordinatorRegistry === registry) + #expect(registry.existingCoordinator(for: conversationID) == nil) + #expect(appState.servicesVersion == versionBefore + 1) + #expect(appState.offlineDataStore == nil) + + viewModel.configure( + dependencies: appState.makeChatViewModelDependencies(), + onNavigateToMap: nil, + linkPreviewCache: nil, + chatCoordinatorRegistry: registry, + conversation: .dm(contact) + ) + let reloaded = await viewModel.primeInitialMessages( + for: contact, + populateMode: .refreshWindow + ) + #expect(reloaded == false) + #expect(viewModel.messages.isEmpty) + #expect(viewModel.coordinator != nil) + #expect(viewModel.coordinator?.messages.isEmpty == true) + } + + @Test + func `forgetting a non-last-connected device does not fire onLastConnectedDeviceCleared`() async throws { + let suiteName = "test.last-connected-clear.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let container = try PersistenceStore.createContainer(inMemory: true) + let manager = ConnectionManager(modelContainer: container, defaults: defaults) + + var fired = false + manager.onLastConnectedDeviceCleared = { fired = true } + + let lastID = UUID() + manager.persistConnection(deviceID: lastID, radioID: UUID(), deviceName: "Holder") + await manager.clearPersistedConnection(for: UUID()) + #expect(fired == false) + + await manager.clearPersistedConnection(for: lastID) + #expect(fired) + } +} + +private func makeForgetTestContact(radioID: UUID) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "forget \(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + 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 + ) +} diff --git a/MC1Tests/Intents/SendMessageIntentTests.swift b/MC1Tests/Intents/SendMessageIntentTests.swift index 89166894a..f41b07032 100644 --- a/MC1Tests/Intents/SendMessageIntentTests.swift +++ b/MC1Tests/Intents/SendMessageIntentTests.swift @@ -64,7 +64,7 @@ struct SendMessageIntentTests { let container = try PersistenceStore.createContainer(inMemory: true) let services = ServiceContainer( session: MeshCoreSession(transport: MockTransport()), - modelContainer: container, + dataStore: PersistenceStore(modelContainer: container), radioID: Self.radioID ) appState.connectionManager.setTestState( diff --git a/MC1Tests/Services/InlineImagePrefetcherTests.swift b/MC1Tests/Services/InlineImagePrefetcherTests.swift index b0c51bc74..a1abc1831 100644 --- a/MC1Tests/Services/InlineImagePrefetcherTests.swift +++ b/MC1Tests/Services/InlineImagePrefetcherTests.swift @@ -288,6 +288,23 @@ private actor StubDataStore: PersistenceStoreProtocol { [] } + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } diff --git a/MC1Tests/Services/LinkPreviewCacheTests.swift b/MC1Tests/Services/LinkPreviewCacheTests.swift index c4480a4ec..46c8d70f9 100644 --- a/MC1Tests/Services/LinkPreviewCacheTests.swift +++ b/MC1Tests/Services/LinkPreviewCacheTests.swift @@ -242,6 +242,23 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { [] } + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } diff --git a/MC1Tests/State/ChatPrewarmRefresherTests.swift b/MC1Tests/State/ChatPrewarmRefresherTests.swift index 1004d14fe..984aa71c5 100644 --- a/MC1Tests/State/ChatPrewarmRefresherTests.swift +++ b/MC1Tests/State/ChatPrewarmRefresherTests.swift @@ -184,9 +184,9 @@ struct ChatPrewarmRefresherTests { viewModel.applyEnvInputs(.default) switch conversation { case let .dm(contact): - await viewModel.primeInitialMessages(for: contact) + await viewModel.primeInitialMessages(for: contact, populateMode: .replace) case let .channel(channel): - await viewModel.primeInitialChannelMessages(for: channel) + await viewModel.primeInitialChannelMessages(for: channel, populateMode: .replace) } } diff --git a/MC1Tests/State/ChatTimelineFreshnessTests.swift b/MC1Tests/State/ChatTimelineFreshnessTests.swift index 1138040be..33f809bf0 100644 --- a/MC1Tests/State/ChatTimelineFreshnessTests.swift +++ b/MC1Tests/State/ChatTimelineFreshnessTests.swift @@ -150,8 +150,8 @@ struct ChatTimelineFreshnessTests { bake: bake, envInputs: .default, senderTables: .empty, - reactions: nil, - postApply: nil + postApply: nil, + anchorSortDate: nil ) guard case .loaded = outcome else { Issue.record("populate outcome was \(outcome), expected .loaded") @@ -289,7 +289,7 @@ struct ChatTimelineFreshnessTests { conversation: .dm(contact) ) viewModel.applyEnvInputs(.default) - await viewModel.primeInitialMessages(for: contact) + await viewModel.primeInitialMessages(for: contact, populateMode: .replace) let id = ChatConversationID.dm(radioID: radioID, contactID: contact.id) let coordinator = try #require(registry.existingCoordinator(for: id)) diff --git a/MC1Tests/ViewModels/AppBackupViewModelTests.swift b/MC1Tests/ViewModels/AppBackupViewModelTests.swift index a0992da90..629f2461c 100644 --- a/MC1Tests/ViewModels/AppBackupViewModelTests.swift +++ b/MC1Tests/ViewModels/AppBackupViewModelTests.swift @@ -128,7 +128,7 @@ struct AppBackupViewModelTests { let radioID = UUID() let services = try ServiceContainer( session: MeshCoreSession(transport: MockTransport()), - modelContainer: PersistenceStore.createContainer(inMemory: true), + dataStore: manager.persistenceStore, radioID: radioID ) try await services.dataStore.saveContact( @@ -186,12 +186,96 @@ struct AppBackupViewModelTests { if case .importing = vm.importState { Issue.record("import ran while connected") } } + @Test + func `performImport writes through the process persistence store`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let manager = ConnectionManager(modelContainer: container) + let radioID = UUID() + let publicKey = Data(repeating: 0x51, count: 32) + try await manager.persistenceStore.saveContact( + makeContact( + radioID: radioID, + publicKey: publicKey, + nickname: nil, + isBlocked: false, + unreadCount: 0 + ) + ) + + let vm = AppBackupViewModel(connectionManager: manager) + vm.importState = .preview( + AppBackupEnvelope( + appVersion: "test", + appBuild: "1", + contacts: [ + makeContact( + radioID: radioID, + publicKey: publicKey, + nickname: "Field Ops", + isBlocked: true, + unreadCount: 7 + ) + ] + ) + ) + vm.performImport() + + try await waitUntil("Import never completed") { + switch vm.importState { + case .success, .failed, .cancelled: true + default: false + } + } + + guard case .success = vm.importState else { + Issue.record("import did not succeed: \(String(describing: vm.importState))") + return + } + + let after = try #require( + await manager.persistenceStore.fetchContact(radioID: radioID, publicKey: publicKey) + ) + #expect(after.nickname == "Field Ops") + #expect(after.isBlocked == true) + #expect(after.unreadCount == 7) + } + private func makeViewModel() throws -> AppBackupViewModel { let container = try PersistenceStore.createContainer(inMemory: true) let manager = ConnectionManager(modelContainer: container) return AppBackupViewModel(connectionManager: manager) } + private func makeContact( + radioID: UUID, + publicKey: Data, + nickname: String?, + isBlocked: Bool, + unreadCount: Int + ) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: publicKey, + name: "Alice", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nickname, + isBlocked: isBlocked, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: unreadCount + ) + } + private func makeReadableBackupURL() throws -> URL { let envelope = AppBackupEnvelope( appVersion: "test", diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index 7898d3272..aca76a2d3 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -197,7 +197,7 @@ struct ChatViewModelPaginationTests { try await dataStore.saveMessage(message) } - #expect(await viewModel.primeInitialMessages(for: contact), "Initial open must succeed") + #expect(await viewModel.primeInitialMessages(for: contact, populateMode: .replace), "Initial open must succeed") #expect(viewModel.messages.count == ChatCoordinator.pageSize) #expect(viewModel.isLoadingOlder == false) @@ -267,7 +267,7 @@ struct ChatViewModelChannelPaginationTests { // the load task runs; an unstaged open reads as already presented at the // bottom and bakes no divider. viewModel.timeline.stageOpen(.channel(channel)) - await viewModel.loadChannelMessages(for: channel) + await viewModel.loadChannelMessages(for: channel, populateMode: .replace) #expect(viewModel.messages.count == ChatCoordinator.initialPageSize(unreadCount: unread), "Initial load must fetch all unread plus read context, not just one page") @@ -295,10 +295,10 @@ struct ChatViewModelChannelPaginationTests { 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) + await viewModel.loadChannelMessages(for: channel, populateMode: .replace) #expect(viewModel.currentChannel?.index == channelIndex) - await viewModel.loadMessages(for: contact) + await viewModel.loadMessages(for: contact, populateMode: .replace) #expect(viewModel.currentChannel == nil, "Loading a DM must clear the channel axis") #expect(viewModel.currentContact?.id == contactID) diff --git a/MC1Tests/ViewModels/ChatViewModelTests.swift b/MC1Tests/ViewModels/ChatViewModelTests.swift index 3af3c11da..e5712d2fa 100644 --- a/MC1Tests/ViewModels/ChatViewModelTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelTests.swift @@ -524,7 +524,7 @@ struct ChatViewModelTests { let coordinator = ChatCoordinator.makeForTesting() viewModel.bindCoordinatorForTesting(coordinator) - await viewModel.loadMessages(for: createTestContact()) + await viewModel.loadMessages(for: createTestContact(), populateMode: .replace) #expect(viewModel.renderState.phase == .loaded) } @@ -540,7 +540,7 @@ struct ChatViewModelTests { index: 1, name: "Test" )) - await viewModel.loadChannelMessages(for: channel) + await viewModel.loadChannelMessages(for: channel, populateMode: .replace) #expect(viewModel.renderState.phase == .loaded) } diff --git a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift index a8fdeabb6..e7e25e466 100644 --- a/MC1Tests/ViewModels/LineOfSightViewModelTests.swift +++ b/MC1Tests/ViewModels/LineOfSightViewModelTests.swift @@ -104,6 +104,23 @@ actor MockPersistenceStore: PersistenceStoreProtocol { [] } + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } diff --git a/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift b/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift index f4abc5372..bcc136d99 100644 --- a/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift +++ b/MC1Tests/ViewModels/RemoteNodeStatusHandlerSurvivalTests.swift @@ -33,7 +33,7 @@ struct RemoteNodeStatusHandlerSurvivalTests { private func makeServices() throws -> ServiceContainer { try ServiceContainer( session: MeshCoreSession(transport: MockTransport()), - modelContainer: PersistenceStore.createContainer(inMemory: true), + dataStore: PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)), radioID: UUID() ) } diff --git a/MC1Tests/ViewModels/TracePathListenerTests.swift b/MC1Tests/ViewModels/TracePathListenerTests.swift index 22d1ba923..07d66589f 100644 --- a/MC1Tests/ViewModels/TracePathListenerTests.swift +++ b/MC1Tests/ViewModels/TracePathListenerTests.swift @@ -19,7 +19,7 @@ struct TracePathListenerTests { private func makeServices() throws -> ServiceContainer { try ServiceContainer( session: MeshCoreSession(transport: MockTransport()), - modelContainer: PersistenceStore.createContainer(inMemory: true), + dataStore: PersistenceStore(modelContainer: PersistenceStore.createContainer(inMemory: true)), radioID: UUID() ) } diff --git a/MC1Tests/Views/Chats/ChatReconnectPopulateTests.swift b/MC1Tests/Views/Chats/ChatReconnectPopulateTests.swift new file mode 100644 index 000000000..ee1b42b16 --- /dev/null +++ b/MC1Tests/Views/Chats/ChatReconnectPopulateTests.swift @@ -0,0 +1,996 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +/// Reconnect must refresh the open timeline in place instead of replacing it. +@Suite("Chat reconnect populate", .serialized) +@MainActor +struct ChatReconnectPopulateTests { + @Test + func `refreshWindow keeps the paged-in window`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: ChatCoordinator.pageSize + 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + let hasMoreBefore = env.viewModel.hasMoreMessages + #expect(countBefore > ChatCoordinator.pageSize) + #expect(hasMoreBefore) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + + #expect(loaded) + await env.viewModel.coordinator?.buildItemsTask?.value + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.messages.map(\.id) == env.viewModel.renderState.items.map(\.id)) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + #expect(env.viewModel.hasMoreMessages == hasMoreBefore) + } + + @Test + func `refreshWindow load still clears unread and returns success`() async throws { + let env = try await makeLoadedEnvironment(unreadCount: 4) + #expect(env.viewModel.messages.isEmpty == false) + + await env.viewModel.loadMessages(for: env.contact, populateMode: .refreshWindow) + #expect(env.viewModel.errorMessage == nil) + + let updated = try await env.dataStore.fetchContact(id: env.contact.id) + #expect(updated?.unreadCount == 0) + } + + @Test + func `replace mode replaces the paged-in window`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + #expect(env.viewModel.messages.count > ChatCoordinator.pageSize) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .replace + ) + + #expect(loaded) + #expect(env.viewModel.messages.count == ChatCoordinator.pageSize) + #expect(!env.viewModel.messages.contains { $0.id == oldestID }) + } + + @Test + func `refreshWindow clears a stuck isLoadingOlder`() async throws { + let env = try await makeLoadedEnvironment() + env.viewModel.timeline.writer?.updateRenderState { $0.with(isLoadingOlder: true) } + #expect(env.viewModel.isLoadingOlder) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + + #expect(loaded) + #expect(env.viewModel.isLoadingOlder == false) + #expect(env.viewModel.totalFetchedCount == ChatCoordinator.pageSize) + } + + @Test + func `first open of a primed coordinator runs populate and bakes the divider`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let unread = 3 + let contact = makeContact(radioID: radioID, unreadCount: unread) + try await dataStore.saveContact(contact) + for index in 0..<(ChatCoordinator.pageSize + unread) { + try await dataStore.saveMessage( + makeMessage( + radioID: radioID, + contactID: contact.id, + timestamp: UInt32(1000 + index) + ) + ) + } + + let coordinator = ChatCoordinator( + conversationID: .dm(radioID: radioID, contactID: contact.id), + dataStore: dataStore + ) + + // Warm the coordinator the way the navigation prefetch does: another + // writer populates it before the view's own open has run. + let primer = ChatViewModel() + primer.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) + primer.bindCoordinatorForTesting(coordinator) + #expect(await primer.primeInitialMessages(for: contact, populateMode: .replace)) + + let viewModel = ChatViewModel() + viewModel.configureForTesting(dependencies: .testDefaults(dataStore: { dataStore })) + viewModel.bindCoordinatorForTesting(coordinator) + viewModel.timeline.stageOpen(.dm(contact)) + #expect(viewModel.timeline.initialLoadSettled == false) + + let loaded = await viewModel.primeInitialMessages(for: contact, populateMode: .refreshWindow) + + #expect(loaded) + #expect(viewModel.timeline.initialLoadSettled, + "First open must run populate, not skip the warm window") + let dividerID = try #require(viewModel.bake.newMessagesDividerMessageID, + "The interactive open must bake the New Messages divider") + await coordinator.buildItemsTask?.value + #expect(viewModel.timeline.firstSnapshot == .present(target: dividerID), + "A staged open with unread must present at the divider, not withhold") + } + + @Test + func `never-paired conversation open is unavailable`() async { + let appState = AppState() + defer { appState.shutdown() } + appState.connectionManager.testForceNeverPaired = true + + #expect(appState.connectionManager.lastConnectedDeviceID == nil) + #expect(appState.offlineDataStore == nil) + #expect(appState.ensureChatCoordinatorRegistry() == nil) + #expect(appState.chatCoordinatorRegistry == nil) + + let contact = makeContact(radioID: UUID(), unreadCount: 0) + let viewModel = ChatViewModel() + viewModel.configure( + dependencies: appState.makeChatViewModelDependencies(), + onNavigateToMap: nil, + linkPreviewCache: nil, + chatCoordinatorRegistry: appState.ensureChatCoordinatorRegistry(), + conversation: .dm(contact) + ) + viewModel.applyEnvInputs(.default) + + let loaded = await viewModel.primeInitialMessages( + for: contact, + populateMode: .refreshWindow + ) + #expect(loaded == false) + #expect(appState.chatCoordinatorRegistry == nil) + #expect(viewModel.renderState.phase == .uninitialized) + } + + @Test + func `catch-up delivery admits once after refreshWindow`() async throws { + let env = try await makeLoadedEnvironment() + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(loaded) + + let incoming = makeMessage( + radioID: env.contact.radioID, + contactID: env.contact.id, + timestamp: 9000 + ) + try await env.dataStore.saveMessage(incoming) + + await env.viewModel.handle(.directMessageReceived(message: incoming, contact: env.contact)) + #expect(env.viewModel.messages.contains { $0.id == incoming.id }) + let countAfterAdmit = env.viewModel.messages.count + + await env.viewModel.handle(.directMessageReceived(message: incoming, contact: env.contact)) + #expect(env.viewModel.messages.count == countAfterAdmit) + } + + @Test + func `refreshWindow keeps the paged-in channel window and sender tables`() async throws { + let env = try await makeLoadedChannelEnvironment(extraOlderCount: ChatCoordinator.pageSize + 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + let hasMoreBefore = env.viewModel.hasMoreMessages + let senderNamesBefore = env.viewModel.channelSenderNames + let senderOrderBefore = env.viewModel.channelSenderOrder + let oldestLoadedSender = try #require(env.viewModel.messages.first?.senderNodeName) + #expect(countBefore > ChatCoordinator.pageSize) + #expect(hasMoreBefore) + #expect(senderNamesBefore.contains(oldestLoadedSender)) + + let loaded = await env.viewModel.primeInitialChannelMessages( + for: env.channel, + populateMode: .refreshWindow + ) + + #expect(loaded) + await env.viewModel.coordinator?.buildItemsTask?.value + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.messages.map(\.id) == env.viewModel.renderState.items.map(\.id)) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + #expect(env.viewModel.hasMoreMessages == hasMoreBefore) + #expect(env.viewModel.channelSenderNames == senderNamesBefore) + #expect(env.viewModel.channelSenderOrder == senderOrderBefore) + } + + @Test + func `replace mode replaces the paged-in channel window`() async throws { + let env = try await makeLoadedChannelEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + #expect(env.viewModel.messages.count > ChatCoordinator.pageSize) + #expect(env.viewModel.channelSenderNames.contains(Self.oldestChannelSenderName)) + + let loaded = await env.viewModel.primeInitialChannelMessages( + for: env.channel, + populateMode: .replace + ) + + #expect(loaded) + #expect(env.viewModel.messages.count == ChatCoordinator.pageSize) + #expect(!env.viewModel.messages.contains { $0.id == oldestID }) + #expect(!env.viewModel.channelSenderNames.contains(Self.oldestChannelSenderName)) + } + + @Test + func `refreshWindow reconciles a silent store status rewrite`() async throws { + let env = try await makeLoadedEnvironment() + let target = try #require(env.viewModel.messages.last) + #expect(target.status == .delivered) + + try await env.dataStore.updateMessageStatus(id: target.id, status: .failed) + #expect(env.viewModel.messagesByID[target.id]?.status == .delivered) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + + #expect(loaded) + #expect(env.viewModel.messagesByID[target.id]?.status == .failed) + } + + @Test + func `refreshWindow reindexes reactions on a fresh ReactionService`() async throws { + let env = try await makeLoadedEnvironment() + let target = try #require(env.viewModel.messages.last) + let freshReactions = ReactionService() + env.viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { env.dataStore }, + reactionService: { freshReactions } + ) + ) + try env.viewModel.bindCoordinatorForTesting(#require(env.viewModel.coordinator)) + + let loaded = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(loaded) + + let hash = ReactionParser.generateMessageHash( + text: target.text, + timestamp: target.reactionTimestamp + ) + let found = await freshReactions.findDMTargetMessage( + messageHash: hash, + contactID: env.contact.id + ) + #expect(found == target.id) + } + + @Test + func `failed populate then refreshWindow retries with the intact window`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + #expect(countBefore > ChatCoordinator.pageSize) + + env.viewModel.timeline.testPopulateError = PopulateTestError.fetchFailed + defer { env.viewModel.timeline.testPopulateError = nil } + + let failed = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(failed == false) + #expect(env.viewModel.errorMessage != nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + + env.viewModel.timeline.testPopulateError = nil + + let retried = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(retried) + #expect(env.viewModel.errorMessage == nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + } + + @Test + func `populate catch keeps counters so refreshWindow retries the full window`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + #expect(countBefore > ChatCoordinator.pageSize) + + env.viewModel.timeline.testPopulateFetchError = PopulateTestError.fetchFailed + defer { env.viewModel.timeline.testPopulateFetchError = nil } + + let failed = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(failed == false) + #expect(env.viewModel.errorMessage != nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + + env.viewModel.timeline.testPopulateFetchError = nil + + let retried = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(retried) + #expect(env.viewModel.errorMessage == nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + } + + @Test + func `cancelled populate keeps counters and does not set errorMessage`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let countBefore = env.viewModel.messages.count + let fetchedBefore = env.viewModel.totalFetchedCount + + env.viewModel.timeline.testPopulateFetchError = CancellationError() + defer { env.viewModel.timeline.testPopulateFetchError = nil } + + let cancelled = await env.viewModel.primeInitialMessages( + for: env.contact, + populateMode: .refreshWindow + ) + #expect(cancelled == false) + #expect(env.viewModel.errorMessage == nil) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + } + + @Test + func `refreshWindow after newer store rows reports hasMoreMessages`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: ChatCoordinator.pageSize + 12) + #expect(env.viewModel.hasMoreMessages == true) + #expect(env.viewModel.totalFetchedCount == ChatCoordinator.pageSize * 2) + + let newestLoaded = try #require(env.viewModel.messages.last?.timestamp) + for index in 0.. ChatCoordinator.pageSize) + + coordinator.hardReset(reason: "test") + await coordinator.hardResetTask?.value + + #expect(env.viewModel.totalFetchedCount == fetchedBefore) + #expect(env.viewModel.messages.count == countBefore) + #expect(env.viewModel.messages.contains { $0.id == oldestID }) + #expect(env.viewModel.hasMoreMessages == true) + + await env.viewModel.loadOlderMessages() + + let expectedCount = ChatCoordinator.pageSize + extraOlderCount + #expect(env.viewModel.messages.count == expectedCount) + #expect(env.viewModel.totalFetchedCount == expectedCount) + let timestamps = env.viewModel.messages.map(\.timestamp) + #expect(timestamps == timestamps.sorted()) + #expect(env.viewModel.messages.first?.timestamp == 1000) + } + + @Test + func `refreshWindow after hidden outgoing reactions retains the oldest row`() async throws { + let env = try await makeLoadedEnvironment(extraOlderCount: 12) + let oldestID = try #require(env.viewModel.messages.first?.id) + let visibleCountBefore = env.viewModel.messages.count + let newestLoaded = try #require(env.viewModel.messages.last?.timestamp) + + let hiddenReactionCount = 2 + for index in 0.. LoadedEnvironment { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID, unreadCount: unreadCount) + try await dataStore.saveContact(contact) + + let total = ChatCoordinator.pageSize + extraOlderCount + for index in 0.. 0 { + await viewModel.loadOlderMessages() + } + return LoadedEnvironment(dataStore: dataStore, viewModel: viewModel, contact: contact) + } + + private struct LoadedChannelEnvironment { + let viewModel: ChatViewModel + let channel: ChannelDTO + } + + private func makeLoadedChannelEnvironment( + extraOlderCount: Int = 0 + ) async throws -> LoadedChannelEnvironment { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channel = makeChannel(radioID: radioID) + try await dataStore.saveChannel(channel) + + let total = ChatCoordinator.pageSize + extraOlderCount + for index in 0.. 0 { + await viewModel.loadOlderMessages() + } + return LoadedChannelEnvironment(viewModel: viewModel, channel: channel) + } +} + +private func makeContact(radioID: UUID, unreadCount: Int) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. ChannelDTO { + ChannelDTO( + id: UUID(), + radioID: radioID, + index: 3, + name: "General", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) +} + +private func makeChannelMessage( + radioID: UUID, + channelIndex: UInt8, + timestamp: UInt32, + senderName: String +) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: nil, + channelIndex: channelIndex, + text: "channel \(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + 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 makeOutgoingReactionMessage( + radioID: UUID, + contactID: UUID, + timestamp: UInt32 +) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "👍\nABCDEFGH", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: .outgoing, + status: .sent, + 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 makeMessage(radioID: UUID, contactID: UUID, timestamp: UInt32) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: nil, + text: "message \(timestamp)", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + 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 + ) +} + +/// Parks `loadOlder` between its fetch and first write. +private actor AsyncGate { + private var waiter: CheckedContinuation? + private var opened = false + + func wait() async { + if opened { return } + await withCheckedContinuation { waiter = $0 } + } + + func open() { + opened = true + waiter?.resume() + waiter = nil + } +} diff --git a/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift b/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift index 761294b2e..cd459970e 100644 --- a/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift +++ b/MC1Tests/Views/Chats/ChatTimelineClobberRegressionTests.swift @@ -192,7 +192,7 @@ struct ChatTimelineClobberRegressionTests { lastModified: 0, lastHeardTimestamp: 0 )) - _ = await viewModel.primeInitialMessages(for: contact) + _ = await viewModel.primeInitialMessages(for: contact, populateMode: .replace) #expect(!fetchStandIn.isCancelled) #expect(viewModel.bake.previewStates[message.id] == .loading) @@ -216,7 +216,7 @@ struct ChatTimelineClobberRegressionTests { lastModified: 0, lastHeardTimestamp: 0 )) - _ = await viewModel.primeInitialMessages(for: otherContact) + _ = await viewModel.primeInitialMessages(for: otherContact, populateMode: .replace) #expect(viewModel.bake.previewStates[message.id] == nil) } diff --git a/MC1Tests/Views/Chats/ChatTimelineTests.swift b/MC1Tests/Views/Chats/ChatTimelineTests.swift index 5af286cae..05a1db707 100644 --- a/MC1Tests/Views/Chats/ChatTimelineTests.swift +++ b/MC1Tests/Views/Chats/ChatTimelineTests.swift @@ -88,7 +88,7 @@ struct ChatTimelineTests { func `open on an unbound timeline reports unavailable`() async { let timeline = ChatTimeline(role: .interactive) let contact = makeContact(radioID: UUID()) - let outcome = await timeline.open(.dm(contact), reactions: nil) + let outcome = await timeline.open(.dm(contact), reactions: nil, populateMode: .replace) guard case .unavailable = outcome else { Issue.record("expected .unavailable, got \(outcome)") return @@ -112,7 +112,7 @@ struct ChatTimelineTests { dataStore: dataStore, conversationID: .dm(radioID: radioID, contactID: contact.id) ) - let outcome = await timeline.open(.dm(contact), reactions: nil) + let outcome = await timeline.open(.dm(contact), reactions: nil, populateMode: .replace) guard case .loaded = outcome else { Issue.record("expected .loaded, got \(outcome)") @@ -120,7 +120,34 @@ struct ChatTimelineTests { } #expect(timeline.messages.count == ChatCoordinator.pageSize) #expect(timeline.messages.first?.text == "m10") - #expect(timeline.renderState.hasMoreMessages) + #expect(timeline.renderState.hasMoreMessages == true) + } + + @Test + func `open of exactly one page reports no further history`() async throws { + let dataStore = try makeStore() + let radioID = UUID() + let contact = makeContact(radioID: radioID) + for offset in 0.. (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } diff --git a/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift b/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift index 35d1d8496..e2d5db7f7 100644 --- a/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift +++ b/MC1Tests/Views/Tools/CLI/CLIToolViewModelTests.swift @@ -506,6 +506,23 @@ actor ParkingContactStore: PersistenceStoreProtocol { [] } + func fetchMessageWindow( + contactID: UUID, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + + func fetchMessageWindow( + radioID: UUID, + channelIndex: UInt8, + anchorSortDate: Date?, + floorLimit: Int + ) async throws -> (messages: [MessageDTO], hasMore: Bool) { + ([], false) + } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } From fa91bdcb39181a51f965116b34b669caec0f1751 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:01:01 -0700 Subject: [PATCH 16/47] chore(sim): seed multi-match flood regions - Unique and ambiguous region labels on Frank and Public floods - Two RX-log rows for the same unique/ambiguous cases - Hop IDs on Alice's Public flood (live RX-log receive) --- .../MockDataProvider+ChannelMessages.swift | 16 +++++ .../Simulator/MockDataProvider+Channels.swift | 2 +- .../Simulator/MockDataProvider+Contacts.swift | 2 +- .../MockDataProvider+DMMessages.swift | 26 ++++++-- .../Simulator/MockDataProvider+RxLog.swift | 59 +++++++++++++++++++ .../Simulator/MockDataProvider.swift | 10 ++++ .../Simulator/MockMessageFactory.swift | 6 +- .../Simulator/SimulatorConnectionMode.swift | 14 +++++ .../MC1ServicesTests/SimulatorSeedTests.swift | 38 +++++++++++- 9 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 MC1Services/Sources/MC1Services/Simulator/MockDataProvider+RxLog.swift diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift index 402dfeb0d..cd68d4045 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift @@ -115,6 +115,22 @@ extension MockDataProvider { channelIndex: publicChannelIndex, ackCode: 50001, pathLength: path + ), + // RX-log correlated flood: hop IDs plus a multi-match region. + MockMessageFactory.message( + id: publicAmbiguousRegionMessageID, + createdAt: now.addingTimeInterval(-600), + text: "Flood from a region that matches two names on this radio", + direction: .incoming, + channelIndex: publicChannelIndex, + pathLength: path, + snr: 6.2, + pathNodes: Data([0x10, 0x20]), + senderKeyPrefix: mockPublicKey(seed: 10).prefix(6), + senderNodeName: "Alice Chen", + routeType: .tcFlood, + regionScope: nil, + regionScopeMatches: ambiguousRegionNames ) ] } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift index 30d4b9d13..7374adf43 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Channels.swift @@ -14,7 +14,7 @@ extension MockDataProvider { name: "Public", secret: channelSecret(seed: 0xA0), isEnabled: true, - lastMessageDate: now.addingTimeInterval(-1200), + lastMessageDate: now.addingTimeInterval(-600), unreadCount: 2, notificationLevel: .all, isFavorite: false diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift index cc612115a..c701028be 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+Contacts.swift @@ -140,7 +140,7 @@ public extension MockDataProvider { isBlocked: false, isMuted: false, isFavorite: false, - lastMessageDate: now.addingTimeInterval(-7200), // 2 hours ago + lastMessageDate: now.addingTimeInterval(-1800), unreadCount: 0 ), diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift index 0e08a9bbb..46a95307f 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+DMMessages.swift @@ -218,8 +218,8 @@ extension MockDataProvider { ] } - /// 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). + /// Frank "Dad" (2-hop): weak/very-weak SNR, unique and ambiguous flood-region + /// incoming, and a heard-repeat-backed outgoing (repeats seeded separately). private static func frankMessages(now: Date) -> [MessageDTO] { let key = mockPublicKey(seed: 60).prefix(6) return [ @@ -258,9 +258,9 @@ extension MockDataProvider { pathNodes: Data([0x10, 0x4F, 0x60, 0x9C]), senderKeyPrefix: key ), - // Flood-routed incoming carrying a region scope. + // Flood-routed incoming carrying a unique region. MockMessageFactory.message( - id: UUID(uuidString: "60000000-0000-0000-0000-000000000004")!, + id: frankFloodUniqueMessageID, createdAt: now.addingTimeInterval(-3600), text: "Storm warning for the ridge tonight ⛈️", direction: .incoming, @@ -270,7 +270,23 @@ extension MockDataProvider { pathNodes: Data([0x10, 0x44, 0x60]), senderKeyPrefix: key, routeType: .tcFlood, - regionScope: "US915" + regionScope: uniqueRegionName, + regionScopeMatches: [uniqueRegionName] + ), + // Flood-routed incoming whose transport code matches two known regions. + MockMessageFactory.message( + id: frankFloodAmbiguousMessageID, + createdAt: now.addingTimeInterval(-1800), + text: "Same storm, heard under two regions", + direction: .incoming, + contactID: frankWilsonID, + pathLength: encodePathLen(hashSize: 1, hopCount: 3), + snr: 3.0, + pathNodes: Data([0x10, 0x44, 0x60]), + senderKeyPrefix: key, + routeType: .tcFlood, + regionScope: nil, + regionScopeMatches: ambiguousRegionNames ) ] } diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+RxLog.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+RxLog.swift new file mode 100644 index 000000000..d8e633b14 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+RxLog.swift @@ -0,0 +1,59 @@ +import Foundation + +extension MockDataProvider { + /// Unique and ambiguous flood-region rows. `decodedText` is omitted because + /// it is `@Transient` on `RxLogEntry` and `saveRxLogEntry` does not persist it. + static var rxLogEntries: [RxLogEntryDTO] { + [ + rxLogEntry( + id: uniqueRxLogEntryID, + receivedAtOffset: -3600, + packetPayload: Data([0x01, 0xAA, 0xBB]), + regionScope: uniqueRegionName, + regionScopeMatches: [uniqueRegionName] + ), + rxLogEntry( + id: ambiguousRxLogEntryID, + receivedAtOffset: -1800, + packetPayload: Data([0x02, 0xCC, 0xDD]), + regionScope: nil, + regionScopeMatches: ambiguousRegionNames + ) + ] + } + + private static let seedTransportCode = Data([0x11, 0x22, 0x33, 0x44]) + + private static func rxLogEntry( + id: UUID, + receivedAtOffset: TimeInterval, + packetPayload: Data, + regionScope: String?, + regionScopeMatches: [String] + ) -> RxLogEntryDTO { + let parsed = ParsedRxLogData( + snr: 8.0, + rssi: -70, + rawPayload: Data([0x15, 0x01, 0x02, 0x03]), + routeType: .tcFlood, + payloadType: .groupText, + payloadVersion: 0, + payloadTypeBits: 5, + transportCode: seedTransportCode, + pathLength: 1, + pathNodes: [0x42], + packetPayload: packetPayload + ) + return RxLogEntryDTO( + id: id, + radioID: simulatorDeviceID, + receivedAt: Date().addingTimeInterval(receivedAtOffset), + from: parsed, + channelIndex: publicChannelIndex, + channelName: "Public", + decryptStatus: .success, + regionScope: regionScope, + regionScopeMatches: regionScopeMatches + ) + } +} diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift index e4bcf0aab..0bf0e34e2 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider.swift @@ -37,8 +37,18 @@ public enum MockDataProvider { 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 frankFloodUniqueMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000004")! + static let frankFloodAmbiguousMessageID = UUID(uuidString: "60000000-0000-0000-0000-000000000005")! static let bayAreaReactedMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000002")! static let bayAreaMentionMessageID = UUID(uuidString: "C1000000-0000-0000-0000-000000000003")! + static let publicAmbiguousRegionMessageID = UUID(uuidString: "C0000000-0000-0000-0000-000000000005")! + static let uniqueRxLogEntryID = UUID(uuidString: "A0000000-0000-0000-0000-000000000001")! + static let ambiguousRxLogEntryID = UUID(uuidString: "A0000000-0000-0000-0000-000000000002")! + + /// Seed values matching `RegionScopeSemantics.storageFields`: unique is + /// `(name, [name])`, ambiguous is `(nil, sorted names)`. + static let uniqueRegionName = "US915" + static let ambiguousRegionNames = ["de-by", "de-hh"] // MARK: - Mock Public Keys diff --git a/MC1Services/Sources/MC1Services/Simulator/MockMessageFactory.swift b/MC1Services/Sources/MC1Services/Simulator/MockMessageFactory.swift index 96726ee07..103b925d9 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockMessageFactory.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockMessageFactory.swift @@ -30,7 +30,8 @@ enum MockMessageFactory { timestampCorrected: Bool = false, senderTimestamp: UInt32? = nil, routeType: RouteType? = nil, - regionScope: String? = nil + regionScope: String? = nil, + regionScopeMatches: [String] = [] ) -> MessageDTO { MessageDTO( id: id, @@ -60,7 +61,8 @@ enum MockMessageFactory { timestampCorrected: timestampCorrected, senderTimestamp: senderTimestamp, routeType: routeType, - regionScope: regionScope + regionScope: regionScope, + regionScopeMatches: regionScopeMatches ) } } diff --git a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift index a4d5c1940..802a99159 100644 --- a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift +++ b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift @@ -88,6 +88,7 @@ final class SimulatorConnectionMode { } } + try await seedRxLogEntries(dataStore) try await seedNodeStatusSnapshots(dataStore) logger.info( @@ -96,6 +97,19 @@ final class SimulatorConnectionMode { ) } + /// Inserts the two flood-region RX-log fixtures when they are missing. + /// `saveRxLogEntry` is insert-only, so a second connect must skip existing ids. + private func seedRxLogEntries(_ dataStore: PersistenceStore) async throws { + let existingIDs = try await Set( + dataStore.fetchRxLogEntries( + radioID: MockDataProvider.simulatorDeviceID + ).map(\.id) + ) + for entry in MockDataProvider.rxLogEntries where !existingIDs.contains(entry.id) { + try await dataStore.saveRxLogEntry(entry) + } + } + /// Seeds a node's GPS track once so the location History list and map have /// content to render. Skipped when the node already has a snapshot, so the /// now-relative timestamps aren't restacked into a duplicate track on every diff --git a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift index ae756436a..d152af138 100644 --- a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift @@ -64,10 +64,40 @@ struct SimulatorSeedTests { @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)) + let message = try #require(try await store.fetchMessage(id: MockDataProvider.frankFloodUniqueMessageID)) #expect(message.routeType == .tcFlood) - #expect(message.regionScope == "US915") + #expect(message.regionScope == MockDataProvider.uniqueRegionName) + #expect(message.regionScopeMatches == [MockDataProvider.uniqueRegionName]) + } + + @Test + func `ambiguous flood dual fields round trip`() async throws { + let store = try await seededStore() + + let dm = try #require(try await store.fetchMessage(id: MockDataProvider.frankFloodAmbiguousMessageID)) + #expect(dm.routeType == .tcFlood) + #expect(dm.regionScope == nil) + #expect(dm.regionScopeMatches == MockDataProvider.ambiguousRegionNames) + + let channel = try #require(try await store.fetchMessage(id: MockDataProvider.publicAmbiguousRegionMessageID)) + #expect(channel.routeType == .tcFlood) + #expect(channel.regionScope == nil) + #expect(channel.regionScopeMatches == MockDataProvider.ambiguousRegionNames) + #expect(channel.pathNodes == Data([0x10, 0x20])) + } + + @Test + func `rx log region rows land`() async throws { + let store = try await seededStore() + let entries = try await store.fetchRxLogEntries(radioID: radioID) + let unique = try #require(entries.first { $0.id == MockDataProvider.uniqueRxLogEntryID }) + #expect(unique.regionScope == MockDataProvider.uniqueRegionName) + #expect(unique.regionScopeMatches == [MockDataProvider.uniqueRegionName]) + #expect(unique.transportCode?.isEmpty == false) + + let ambiguous = try #require(entries.first { $0.id == MockDataProvider.ambiguousRxLogEntryID }) + #expect(ambiguous.regionScope == nil) + #expect(ambiguous.regionScopeMatches == MockDataProvider.ambiguousRegionNames) } @Test @@ -96,5 +126,7 @@ struct SimulatorSeedTests { #expect(repeats.count == 3) let reactions = try await store.fetchReactions(for: MockDataProvider.aliceReactedMessageID) #expect(reactions.count == 3) + let rx = try await store.fetchRxLogEntries(radioID: radioID) + #expect(rx.count == 2) } } From d706ed0291b43825dde8e8eb7a4c2346328bdf7e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:11:23 -0700 Subject: [PATCH 17/47] fix(ble): keep auto-reconnect while out of range - After the connect-failure budget, re-issue connect when the bond verified recently or the app is not confirmed active - isAppActive starts false so a BLE process relaunch keeps the pending connect --- .../BLEStateMachine+CallbackHandlers.swift | 40 ++++-- .../Transport/BLEStateMachine.swift | 13 +- .../Transport/ReconnectPolicy.swift | 79 +++++----- .../BondLossPairingRecoveryTests.swift | 1 + ...EStateMachineAutoReconnectRetryTests.swift | 45 +++++- ...StateMachineBondSuspectRecoveryTests.swift | 1 + ...ateMachineFringeEncryptionGraceTests.swift | 90 ++++++++---- .../Transport/BondShieldRefreshTests.swift | 2 + .../Transport/ReconnectPolicyTests.swift | 136 +++++++++++++++--- 9 files changed, 292 insertions(+), 115 deletions(-) diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift index 053ae33cf..0dffd94b0 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine+CallbackHandlers.swift @@ -238,32 +238,36 @@ extension BLEStateMachine { ) // 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; the reconnect policy owns the transient-vs-escalate - // classification, including the encryption-timeout grace. + // watchdog cannot retry, so a non-definitive failure re-issues the connect + // and stays in the episode unless the reconnect policy escalates. if case let .autoReconnecting(expected, _, _) = phase, expected.identifier == peripheral.identifier { - switch reconnectPolicy.resolveConnectFailure(deviceID: peripheral.identifier, error: error, now: Date()) { + switch reconnectPolicy.resolveConnectFailure( + deviceID: peripheral.identifier, + error: error, + now: Date(), + appActive: isAppActive + ) { case let .retryPendingConnect(failureCount, budget): logger.info("[BLE] Transient auto-reconnect connect failure (\(failureCount)/\(budget)) for \(peripheral.identifier.uuidString.prefix(8)); re-issuing pending connect") - let options: [String: Any] = [ - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionEnableAutoReconnect: true - ] - centralManager.connect(peripheral, options: options) + reissueAutoReconnectConnect(peripheral) + + case let .continueEpisodeAfterBudget(reason): + switch reason { + case let .fringeEncryptionGraced(verifiedAge): + logger.warning("[BLE] Encryption-timeout budget exhausted for \(peripheral.identifier.uuidString.prefix(8)); bond verified \(Self.verifiedAgeDescription(verifiedAge)), within grace; re-issuing pending connect") + case .backgroundHold: + logger.warning("[BLE] Auto-reconnect connect failures exhausted budget for \(peripheral.identifier.uuidString.prefix(8)) while inactive; re-issuing pending connect") + } + reissueAutoReconnectConnect(peripheral) case let .tearDown(teardownError, reason): switch reason { case .definitiveBondFailure: logger.warning("Auto-reconnect failed with definitive auth error for \(peripheral.identifier) - transitioning to idle") - case let .fringeEncryptionGraced(verifiedAge): - logger.warning("[BLE] Encryption-timeout budget exhausted for \(peripheral.identifier.uuidString.prefix(8)); bond verified \(Self.verifiedAgeDescription(verifiedAge)), within grace; classifying as transient") case let .bondSuspect(verifiedAge): logger.warning("[BLE] Encryption-timeout budget exhausted for \(peripheral.identifier.uuidString.prefix(8)); bond verified \(Self.verifiedAgeDescription(verifiedAge)), outside grace; escalating to bond-suspect") case .retryBudgetExhausted: - break - } - if case .definitiveBondFailure = reason {} else { logger.warning("Auto-reconnect connect failures exhausted budget for \(peripheral.identifier) - transitioning to idle") } transition(to: .idle) @@ -283,6 +287,14 @@ extension BLEStateMachine { continuation.resume(throwing: ReconnectPolicy.makeConnectionError(error)) } + private func reissueAutoReconnectConnect(_ peripheral: CBPeripheral) { + let options: [String: Any] = [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + CBConnectPeripheralOptionEnableAutoReconnect: true + ] + centralManager.connect(peripheral, options: options) + } + /// Renders a bond-verification age for debug-log export, so a later export /// can distinguish a graced fringe episode from a graced dead bond. static func verifiedAgeDescription(_ age: TimeInterval?) -> String { diff --git a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift index 882bd9d7e..a4759fa0c 100644 --- a/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift +++ b/MC1Services/Sources/MC1Services/Transport/BLEStateMachine.swift @@ -184,9 +184,9 @@ actor BLEStateMachine: BLEStateMachineProtocol { /// 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 + /// Confirmed foreground. Starts false: a BLE process relaunch is already + /// background, and scene `.onChange` does not fire for the initial phase. + private(set) var isAppActive = false /// Tracks whether CBCentralManager has been created private var isActivated = false @@ -446,10 +446,9 @@ actor BLEStateMachine: BLEStateMachineProtocol { onAutoReconnecting = handler } - /// Records that a device's bond completed a verified encrypted session, so an - /// exhausted encryption-timeout budget within the grace window classifies as - /// transient. `ConnectionManager` seeds the persisted date at wiring time and - /// pushes a fresh one after every verified session. + /// Records a verified encrypted session. An exhausted encryption-timeout + /// budget within the grace window then continues the episode rather than + /// tearing down. `ConnectionManager` seeds and refreshes this stamp. func recordBondVerification(deviceID: UUID, at date: Date) { reconnectPolicy.recordBondVerification(deviceID: deviceID, at: date) } diff --git a/MC1Services/Sources/MC1Services/Transport/ReconnectPolicy.swift b/MC1Services/Sources/MC1Services/Transport/ReconnectPolicy.swift index 91981b861..3f1680386 100644 --- a/MC1Services/Sources/MC1Services/Transport/ReconnectPolicy.swift +++ b/MC1Services/Sources/MC1Services/Transport/ReconnectPolicy.swift @@ -19,17 +19,13 @@ struct ReconnectPolicy { /// 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. + /// Consecutive `didFailToConnect` callbacks in one auto-reconnect episode + /// before `resolveConnectFailure` classifies hold versus tear-down. static let maxAutoReconnectConnectFailures = 5 - /// How long after a verified encrypted session an exhausted encryption-timeout - /// budget is still treated as transient rather than a suspect bond. Encryption - /// timeouts at the edge of BLE range are indistinguishable from an invalidated - /// bond attempt-by-attempt; a bond that completed an encrypted session this - /// recently is near-certainly healthy, while a genuinely dead bond can never - /// refresh the verification and escalates once the grace elapses. + /// After a verified encrypted session, an exhausted encryption-timeout + /// majority returns `.continueEpisodeAfterBudget` rather than `.bondSuspect`. + /// Outside this window a dead bond escalates only while the app is active. static let bondVerificationGraceInterval: TimeInterval = 6 * 60 * 60 // MARK: - State @@ -39,8 +35,7 @@ struct ReconnectPolicy { 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. + /// A majority is the ambiguous dead-bond signature used at budget exhaust. var encryptionTimedOutConnectFailures = 0 /// Number of times a discovery watchdog has deferred teardown within the @@ -98,27 +93,40 @@ struct ReconnectPolicy { enum ConnectFailureDecision { /// Re-issue the pending connect; the episode continues. case retryPendingConnect(failureCount: Int, budget: Int) + /// Budget spent: re-issue `connect` and keep the episode. + /// `didFailToConnect` already consumed the pending connect; tearing + /// down would leave only a watchdog whose sleep freezes when suspended. + case continueEpisodeAfterBudget(reason: BudgetHoldReason) /// End the episode, surfacing `error` through `onDisconnection`. case tearDown(error: BLEError, reason: TeardownReason) } + /// Why the episode continued after the connect-failure budget was spent. + /// `verifiedAge` is the time since the bond's last verified encrypted + /// session at decision time, nil when it never verified. + enum BudgetHoldReason { + case fringeEncryptionGraced(verifiedAge: TimeInterval?) + case backgroundHold + } + /// Why a connect-failure `.tearDown` was chosen; drives the diagnostic log /// line. `verifiedAge` is the time since the bond's last verified encrypted /// session at decision time, nil when it never verified. enum TeardownReason { case definitiveBondFailure - case fringeEncryptionGraced(verifiedAge: TimeInterval?) case bondSuspect(verifiedAge: TimeInterval?) case retryBudgetExhausted } - /// `didFailToConnect` arrived while auto-reconnecting. 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 — unless the bond verified an - /// encrypted session recently — so an invalidated bond still reaches guided - /// re-pair. - mutating func resolveConnectFailure(deviceID: UUID, error: Error?, now: Date) -> ConnectFailureDecision { + /// `didFailToConnect` while auto-reconnecting. A definitive bond failure + /// tears down at once; otherwise re-issue until the budget, then hold + /// (recently verified, or inactive) or tear down (active and unshielded). + mutating func resolveConnectFailure( + deviceID: UUID, + error: Error?, + now: Date, + appActive: Bool + ) -> ConnectFailureDecision { if Self.isDefinitiveAuthFailure(error) { resetFailureTallies() return .tearDown(error: .authenticationFailed, reason: .definitiveBondFailure) @@ -136,27 +144,25 @@ struct ReconnectPolicy { ) } - // An encryption-timeout majority is the ambiguous signature of an - // invalidated bond, but it is also what a healthy bond produces when the - // user lingers at the edge of BLE range. A recently verified bond tears - // down as transient (the watchdog keeps retrying); a dead bond can never - // refresh its verification, so it still escalates once the grace elapses. + // Encryption-timeout majority is both a dead-bond signature and fringe-range + // noise. Keep the pending connect when recently verified or inactive; + // escalate to `.bondSuspect` only when the app is active and grace has elapsed. let majorityEncryptionTimeouts = encryptionTimedOutConnectFailures * 2 > autoReconnectConnectFailures resetFailureTallies() - guard majorityEncryptionTimeouts else { - return .tearDown(error: Self.makeConnectionError(error), reason: .retryBudgetExhausted) - } - let lastVerified = bondVerificationDates[deviceID] let verifiedAge = lastVerified.map { now.timeIntervalSince($0) } - if Self.isBondRecentlyVerified(lastVerified: lastVerified, now: now) { - return .tearDown( - error: .connectionFailed("Encryption timed out repeatedly near range limit"), - reason: .fringeEncryptionGraced(verifiedAge: verifiedAge) - ) + + if majorityEncryptionTimeouts, Self.isBondRecentlyVerified(lastVerified: lastVerified, now: now) { + return .continueEpisodeAfterBudget(reason: .fringeEncryptionGraced(verifiedAge: verifiedAge)) + } + if !appActive { + return .continueEpisodeAfterBudget(reason: .backgroundHold) + } + if majorityEncryptionTimeouts { + return .tearDown(error: .authenticationFailed, reason: .bondSuspect(verifiedAge: verifiedAge)) } - return .tearDown(error: .authenticationFailed, reason: .bondSuspect(verifiedAge: verifiedAge)) + return .tearDown(error: Self.makeConnectionError(error), reason: .retryBudgetExhausted) } // MARK: - Discovery-stall classification @@ -230,9 +236,8 @@ struct ReconnectPolicy { /// 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`; connect-failure resolution escalates it only when it - /// dominates an exhausted auto-reconnect retry budget. + /// description. A lone `CBError.encryptionTimedOut` stays `.connectionFailed`; + /// `resolveConnectFailure` holds or escalates it from the exhausted budget. static func makeConnectionError(_ error: Error?, fallback: String = "Unknown error") -> BLEError { if let nsError = error as NSError? { if nsError.domain == CBATTErrorDomain { diff --git a/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift b/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift index 0f88aa539..0e13bc859 100644 --- a/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BondLossPairingRecoveryTests.swift @@ -270,6 +270,7 @@ struct BondLossPairingRecoveryTests { await manager.clearPersistedConnection(for: deviceID) #expect(await sm.bondVerificationDate(for: deviceID) == nil) + await sm.appDidBecomeActive() await sm.primeBondClearAutoReconnecting(peripheral: peripheral) let recorder = BondClearDisconnectionRecorder() await sm.setDisconnectionHandler { id, error in diff --git a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift index 76fa61395..159306061 100644 --- a/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Transport/BLEStateMachineAutoReconnectRetryTests.swift @@ -4,11 +4,9 @@ import Foundation 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. +/// `didFailToConnect` during `.autoReconnecting` re-issues the pending +/// connect and stays in the episode. Tear-down is a definitive auth code, +/// or an exhausted budget while the app is confirmed active. @Suite("BLEStateMachine auto-reconnect connect retry") struct BLEStateMachineAutoReconnectRetryTests { private var encryptionTimedOut: NSError { @@ -52,6 +50,7 @@ struct BLEStateMachineAutoReconnectRetryTests { await sm.injectTestCentralManager() let peripheral = makeLeakedRetryPeripheral() let recorder = await makeRecorder(on: sm) + await sm.appDidBecomeActive() await sm.primeAutoReconnecting(peripheral: peripheral) // Every failure below the cap re-arms silently and stays in the episode. @@ -61,7 +60,7 @@ struct BLEStateMachineAutoReconnectRetryTests { #expect(await sm.currentPhase.name == "autoReconnecting") #expect(recorder.events.isEmpty) - // The failure that reaches the cap gives up. + // The failure that reaches the cap tears the episode down. await sm.handleDidFailToConnect(peripheral, error: encryptionTimedOut) #expect(await sm.currentPhase.name == "idle") @@ -79,6 +78,7 @@ struct BLEStateMachineAutoReconnectRetryTests { await sm.injectTestCentralManager() let peripheral = makeLeakedRetryPeripheral() let recorder = await makeRecorder(on: sm) + await sm.appDidBecomeActive() await sm.primeAutoReconnecting(peripheral: peripheral) for _ in 1.. (BLEStateMachine, FringeTestPeripheral, FringeDisconnectionRecorder) { FringeTestPeripheral.reset() let sm = BLEStateMachine() @@ -31,30 +28,30 @@ struct BLEStateMachineFringeEncryptionGraceTests { if let bondVerified { await sm.recordBondVerification(deviceID: FringeTestPeripheral.uuid, at: bondVerified) } + if appActive { + await sm.appDidBecomeActive() + } await sm.primeFringeAutoReconnecting(peripheral: peripheral) return (sm, peripheral, recorder) } - // MARK: - Fringe replay (the regression test for this bug) + // MARK: - Fringe replay + /// A recently verified encryption-timeout majority re-issues connect + /// instead of tearing down. @Test - func `exhausted encryption-timeout budget with a recently verified bond stays transient`() async { + func `out-of-range encryption timeouts with a live bond keep auto-reconnecting`() async { let (sm, peripheral, recorder) = await makeMachine(bondVerified: Date().addingTimeInterval(-60)) - for _ in 1.. ReconnectPolicy { var policy = ReconnectPolicy() if let bondVerified { @@ -225,11 +227,22 @@ struct ReconnectPolicyConnectFailureTests { private func exhaustBudget( _ policy: inout ReconnectPolicy, error: NSError, - now: Date = Date() + now: Date = Date(), + appActive: Bool = true ) -> ReconnectPolicy.ConnectFailureDecision { - var last = policy.resolveConnectFailure(deviceID: deviceID, error: error, now: now) + var last = policy.resolveConnectFailure( + deviceID: deviceID, + error: error, + now: now, + appActive: appActive + ) for _ in 1.. Date: Wed, 19 Aug 2026 22:00:41 -0400 Subject: [PATCH 18/47] feat(contacts): crop profile picture before saving (#397) * feat(contacts): crop profile picture before saving Adds a pan/pinch crop step between picking a photo (library or file import) and saving it as a contact's avatar, instead of using the picked image as-is. Crops via UIImage.draw(in:) rather than raw CGImage cropping so EXIF-rotated photos crop the same region shown in the on-screen preview. * fix: regenerate L10n.swift with swiftgen The committed generated file didn't match current swiftgen output ordering, tripping the codegen CI check. * chore: retrigger CI * fix(contacts): address avatar crop review feedback Fixes the modal-presentation race between the picker/importer sheets and the crop cover, decodes and downsamples off the main actor via ImageIO instead of full-resolution UIImage(data:), removes the redundant JPEG re-encode by passing the cropped UIImage straight to the existing avatar processor, swaps the deprecated MagnificationGesture for MagnifyGesture with a clamped live pinch value, and drops an unused outer GeometryReader. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: romeo Co-authored-by: Claude Sonnet 5 --- MC1/Resources/Generated/L10n.swift | 8 + .../Localization/en.lproj/Contacts.strings | 9 + MC1/Views/Components/AvatarCropView.swift | 157 ++++++++++++++++++ MC1/Views/Contacts/ContactDetailView.swift | 85 +++++++++- 4 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 MC1/Views/Components/AvatarCropView.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index f15b9b5cb..b663d4933 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1368,6 +1368,14 @@ public enum L10n { public static let removePhoto = L10n.tr("Contacts", "contacts.detail.avatar.removePhoto", fallback: "Remove Photo") /// Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved public static let savingAnnouncement = L10n.tr("Contacts", "contacts.detail.avatar.savingAnnouncement", fallback: "Saving photo") + public enum Crop { + /// Location: AvatarCropView.swift - Purpose: Crop screen cancel button + public static let cancel = L10n.tr("Contacts", "contacts.detail.avatar.crop.cancel", fallback: "Cancel") + /// Location: AvatarCropView.swift - Purpose: Crop screen confirm button + public static let choose = L10n.tr("Contacts", "contacts.detail.avatar.crop.choose", fallback: "Choose") + /// Location: AvatarCropView.swift - Purpose: Crop screen navigation title + public static let title = L10n.tr("Contacts", "contacts.detail.avatar.crop.title", fallback: "Move and Scale") + } } public enum Error { /// Location: ContactDetailView.swift - Purpose: Clear messages services-unavailable error diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index 795c09d4e..bde960977 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -284,6 +284,15 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file isn't a valid image */ "contacts.detail.avatar.invalidImage" = "That file couldn't be used as a profile picture."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Move and Scale"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen cancel button */ +"contacts.detail.avatar.crop.cancel" = "Cancel"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Choose"; + /* Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved */ "contacts.detail.avatar.savingAnnouncement" = "Saving photo"; diff --git a/MC1/Views/Components/AvatarCropView.swift b/MC1/Views/Components/AvatarCropView.swift new file mode 100644 index 000000000..6cf0e5cf3 --- /dev/null +++ b/MC1/Views/Components/AvatarCropView.swift @@ -0,0 +1,157 @@ +import SwiftUI +import UIKit + +/// Lets the user pan and zoom a picked image within a circular guide before it's +/// saved as a contact's profile picture, and crops it down to just that region. +struct AvatarCropView: View { + let image: UIImage + let onCancel: () -> Void + let onComplete: (UIImage) -> Void + + /// Side length, in points, of the square crop guide shown on screen. + private let cropSize: CGFloat = 300 + + /// Allowed zoom range, applied both live during a pinch and once it ends. + private let minScale: CGFloat = 1 + private let maxScale: CGFloat = 4 + + @GestureState private var dragTranslation: CGSize = .zero + @GestureState private var pinchDelta: CGFloat = 1 + + @State private var offset: CGSize = .zero + @State private var scale: CGFloat = 1 + + var body: some View { + NavigationStack { + ZStack { + Color.black.ignoresSafeArea() + + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: baseDisplaySize.width, height: baseDisplaySize.height) + .scaleEffect(clampedScale(scale * pinchDelta)) + .offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height) + .frame(width: cropSize, height: cropSize) + .clipped() + .contentShape(Rectangle()) + .gesture(dragGesture) + .simultaneousGesture(magnificationGesture) + + Circle() + .strokeBorder(Color.white, lineWidth: 2) + .frame(width: cropSize, height: cropSize) + .allowsHitTesting(false) + + dimmingMask + .allowsHitTesting(false) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(L10n.Contacts.Contacts.Detail.Avatar.Crop.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(L10n.Contacts.Contacts.Detail.Avatar.Crop.cancel, action: onCancel) + } + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Contacts.Contacts.Detail.Avatar.Crop.choose) { + onComplete(croppedImage()) + } + } + } + } + } + + /// A full-bleed dark scrim with a circular window cut out over the crop guide, + /// drawn with an even-odd fill so the two shapes combine into a single hole-punched path. + private var dimmingMask: some View { + GeometryReader { proxy in + Path { path in + path.addRect(CGRect(origin: .zero, size: proxy.size)) + let circleRect = CGRect( + x: (proxy.size.width - cropSize) / 2, + y: (proxy.size.height - cropSize) / 2, + width: cropSize, + height: cropSize + ) + path.addEllipse(in: circleRect) + } + .fill(Color.black.opacity(0.5), style: FillStyle(eoFill: true)) + } + } + + /// The image's size, in points, when scaled (via `.scaledToFill`) to just cover the crop square. + private var baseDisplaySize: CGSize { + let imageSize = image.size + guard imageSize.width > 0, imageSize.height > 0 else { return CGSize(width: cropSize, height: cropSize) } + let fillScale = max(cropSize / imageSize.width, cropSize / imageSize.height) + return CGSize(width: imageSize.width * fillScale, height: imageSize.height * fillScale) + } + + private var dragGesture: some Gesture { + DragGesture() + .updating($dragTranslation) { value, state, _ in + state = value.translation + } + .onEnded { value in + offset = clampedOffset( + CGSize(width: offset.width + value.translation.width, height: offset.height + value.translation.height) + ) + } + } + + private var magnificationGesture: some Gesture { + MagnifyGesture() + .updating($pinchDelta) { value, state, _ in + state = value.magnification + } + .onEnded { value in + scale = clampedScale(scale * value.magnification) + offset = clampedOffset(offset) + } + } + + private func clampedScale(_ proposed: CGFloat) -> CGFloat { + min(max(proposed, minScale), maxScale) + } + + /// Keeps the displayed image covering the crop square at all times, regardless of pan/zoom. + private func clampedOffset(_ proposed: CGSize) -> CGSize { + let displayedSize = CGSize(width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale) + let maxOffsetX = max(0, (displayedSize.width - cropSize) / 2) + let maxOffsetY = max(0, (displayedSize.height - cropSize) / 2) + return CGSize( + width: min(max(proposed.width, -maxOffsetX), maxOffsetX), + height: min(max(proposed.height, -maxOffsetY), maxOffsetY) + ) + } + + /// Renders the portion of the source image currently visible inside the crop guide. + /// + /// Draws via `UIImage.draw(in:)` rather than cropping `cgImage` directly, so that EXIF + /// orientation (e.g. a portrait photo shot with a rotated sensor) is honored exactly as + /// it is in the on-screen preview, which uses the same point-space geometry. + private func croppedImage() -> UIImage { + let outputSide: CGFloat = 1024 + let renderScale = outputSide / cropSize + + let displayedSize = CGSize(width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale) + let imageOrigin = CGPoint( + x: (cropSize - displayedSize.width) / 2 + offset.width, + y: (cropSize - displayedSize.height) / 2 + offset.height + ) + + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: outputSide, height: outputSide), format: format) + return renderer.image { _ in + let drawRect = CGRect( + x: imageOrigin.x * renderScale, + y: imageOrigin.y * renderScale, + width: displayedSize.width * renderScale, + height: displayedSize.height * renderScale + ) + image.draw(in: drawRect) + } + } +} diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 59d67c6f1..7c330106c 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -73,6 +73,12 @@ struct ContactDetailView: View { } } + /// Wraps a decoded avatar image so `fullScreenCover(item:)` can't present an empty cover. + private struct AvatarCropRequest: Identifiable { + let id = UUID() + let image: UIImage + } + @State private var currentContact: ContactDTO @State private var nickname = "" @State private var isEditingNickname = false @@ -105,6 +111,10 @@ struct ContactDetailView: View { @State private var showAvatarFileImporter = false @State private var avatarPickerItem: PhotosPickerItem? @State private var isSavingAvatar = false + /// A decoded image waiting for the photo picker / file importer sheet that produced it + /// to finish dismissing, so the crop cover isn't presented while another is still animating out. + @State private var pendingCropImage: UIImage? + @State private var cropRequest: AvatarCropRequest? init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) { self.contact = contact @@ -273,9 +283,25 @@ struct ContactDetailView: View { .onChange(of: avatarPickerItem) { _, newItem in Task { await loadPickedAvatarPhoto(newItem) } } + .onChange(of: showAvatarPhotosPicker) { _, isPresented in + if !isPresented { presentPendingCropIfReady() } + } .fileImporter(isPresented: $showAvatarFileImporter, allowedContentTypes: [.image]) { result in Task { await handleAvatarFileImport(result) } } + .onChange(of: showAvatarFileImporter) { _, isPresented in + if !isPresented { presentPendingCropIfReady() } + } + .fullScreenCover(item: $cropRequest) { request in + AvatarCropView( + image: request.image, + onCancel: { cropRequest = nil }, + onComplete: { cropped in + cropRequest = nil + Task { await saveAvatar(image: cropped) } + } + ) + } .task { pathViewModel.configure( dataStore: { appState.services?.dataStore }, @@ -535,7 +561,7 @@ struct ContactDetailView: View { errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage return } - await saveAvatar(data: data) + await presentCropSheet(data: data) } catch { errorMessage = error.userFacingMessage } @@ -548,7 +574,7 @@ struct ContactDetailView: View { defer { if didAccess { url.stopAccessingSecurityScopedResource() } } do { let data = try Data(contentsOf: url) - await saveAvatar(data: data) + await presentCropSheet(data: data) } catch { errorMessage = error.userFacingMessage } @@ -557,10 +583,36 @@ struct ContactDetailView: View { } } - private func saveAvatar(data: Data) async { + /// Decodes off the main actor and downsamples to a display-sized bound before the crop + /// screen ever sees the image, so a 12-48MP camera photo doesn't hitch the UI or hold + /// its full-resolution bitmap in memory while cropping. + private func presentCropSheet(data: Data) async { + let decoded = await Task.detached(priority: .userInitiated) { + Self.downsampledImage(data: data, maxPixelSize: Self.cropMaxPixelSize) + }.value + guard let image = decoded else { + errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage + return + } + pendingCropImage = image + // The picker/importer sheet may still be animating its dismissal; onChange above + // presents the pending image once it reports fully closed. If it's already closed + // by the time decoding finishes, present immediately. + if !showAvatarPhotosPicker, !showAvatarFileImporter { + presentPendingCropIfReady() + } + } + + private func presentPendingCropIfReady() { + guard let image = pendingCropImage else { return } + pendingCropImage = nil + cropRequest = AvatarCropRequest(image: image) + } + + private func saveAvatar(image: UIImage) async { isSavingAvatar = true let processed = await Task.detached(priority: .userInitiated) { - Self.processAvatarImage(data: data) + Self.processAvatarImage(image: image) }.value guard let processed else { errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage @@ -590,10 +642,31 @@ struct ContactDetailView: View { isSavingAvatar = false } + /// Max pixel dimension the crop screen decodes and displays at; well above the 300pt + /// on-screen guide to stay sharp under the pinch zoom's 4x cap, but bounded so a raw + /// camera photo can't hold its full-resolution bitmap in memory while cropping. + private nonisolated static let cropMaxPixelSize: CGFloat = 1024 + + /// Decodes and downsamples via ImageIO instead of `UIImage(data:)`, so a large source + /// image is never fully decoded into memory. Mirrors `ImageURLDetector.downsampledImage(from:)`. + private nonisolated static func downsampledImage(data: Data, maxPixelSize: CGFloat) -> UIImage? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil } + let downsampleOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { + return nil + } + return UIImage(cgImage: cgImage) + } + /// Downscales to a max 512pt dimension and re-encodes as JPEG so avatars stay small in the store. /// `nonisolated` so it can run on a background thread via `Task.detached` in `saveAvatar`. - private nonisolated static func processAvatarImage(data: Data) -> Data? { - guard let image = UIImage(data: data) else { return nil } + private nonisolated static func processAvatarImage(image: UIImage) -> Data? { let maxDimension: CGFloat = 512 let scale = min(1, maxDimension / max(image.size.width, image.size.height)) let targetSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) From d91b2c8e5d8011c7a9b7ecd510f460f165d587f8 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:18:04 -0700 Subject: [PATCH 19/47] fix(contacts): crop clamp, iPad menu, list refresh - Keep live pan/zoom covering the crop guide so Choose matches the preview - Anchor the iPad source menu on the avatar button - Notify contacts changed after avatar save so the Nodes list updates --- MC1/Resources/Generated/L10n.swift | 14 +- .../Localization/de.lproj/Contacts.strings | 24 ++ .../Localization/en.lproj/Contacts.strings | 21 +- .../Localization/es.lproj/Contacts.strings | 24 ++ .../Localization/fr.lproj/Contacts.strings | 24 ++ .../Localization/it.lproj/Contacts.strings | 24 ++ .../Localization/nl.lproj/Contacts.strings | 24 ++ .../Localization/pl.lproj/Contacts.strings | 24 ++ .../Localization/ru.lproj/Contacts.strings | 24 ++ .../Localization/uk.lproj/Contacts.strings | 24 ++ .../zh-Hans.lproj/Contacts.strings | 24 ++ MC1/Services/ImageURLDetector.swift | 4 +- MC1/Views/Components/AvatarCropGeometry.swift | 89 ++++++++ MC1/Views/Components/AvatarCropView.swift | 210 +++++++++++------- MC1/Views/Contacts/ContactDetailView.swift | 101 ++++----- .../MC1Services/Services/ContactService.swift | 1 + MC1Tests/Services/ImageURLDetectorTests.swift | 18 ++ .../Components/AvatarCropGeometryTests.swift | 88 ++++++++ 18 files changed, 617 insertions(+), 145 deletions(-) create mode 100644 MC1/Views/Components/AvatarCropGeometry.swift create mode 100644 MC1Tests/Views/Components/AvatarCropGeometryTests.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index b663d4933..80f970d92 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -1369,10 +1369,20 @@ public enum L10n { /// Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved public static let savingAnnouncement = L10n.tr("Contacts", "contacts.detail.avatar.savingAnnouncement", fallback: "Saving photo") public enum Crop { - /// Location: AvatarCropView.swift - Purpose: Crop screen cancel button - public static let cancel = L10n.tr("Contacts", "contacts.detail.avatar.crop.cancel", fallback: "Cancel") /// Location: AvatarCropView.swift - Purpose: Crop screen confirm button public static let choose = L10n.tr("Contacts", "contacts.detail.avatar.crop.choose", fallback: "Choose") + /// Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down + public static let moveDown = L10n.tr("Contacts", "contacts.detail.avatar.crop.moveDown", fallback: "Move down") + /// Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left + public static let moveLeft = L10n.tr("Contacts", "contacts.detail.avatar.crop.moveLeft", fallback: "Move left") + /// Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right + public static let moveRight = L10n.tr("Contacts", "contacts.detail.avatar.crop.moveRight", fallback: "Move right") + /// Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up + public static let moveUp = L10n.tr("Contacts", "contacts.detail.avatar.crop.moveUp", fallback: "Move up") + /// Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview + public static let preview = L10n.tr("Contacts", "contacts.detail.avatar.crop.preview", fallback: "Profile picture crop") + /// Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move + public static let previewHint = L10n.tr("Contacts", "contacts.detail.avatar.crop.previewHint", fallback: "Swipe up or down to zoom. Use the actions to move the photo.") /// Location: AvatarCropView.swift - Purpose: Crop screen navigation title public static let title = L10n.tr("Contacts", "contacts.detail.avatar.crop.title", fallback: "Move and Scale") } diff --git a/MC1/Resources/Localization/de.lproj/Contacts.strings b/MC1/Resources/Localization/de.lproj/Contacts.strings index a3d1d832e..674b0c82a 100644 --- a/MC1/Resources/Localization/de.lproj/Contacts.strings +++ b/MC1/Resources/Localization/de.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Diese Datei konnte nicht als Profilbild verwendet werden."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Bewegen und skalieren"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Auswählen"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Zuschnitt des Profilbilds"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Zum Zoomen nach oben oder unten streichen. Mit den Aktionen das Foto verschieben."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Nach oben verschieben"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Nach unten verschieben"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Nach links verschieben"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Nach rechts verschieben"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Spitzname"; diff --git a/MC1/Resources/Localization/en.lproj/Contacts.strings b/MC1/Resources/Localization/en.lproj/Contacts.strings index bde960977..eedfe7cd2 100644 --- a/MC1/Resources/Localization/en.lproj/Contacts.strings +++ b/MC1/Resources/Localization/en.lproj/Contacts.strings @@ -287,12 +287,27 @@ /* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ "contacts.detail.avatar.crop.title" = "Move and Scale"; -/* Location: AvatarCropView.swift - Purpose: Crop screen cancel button */ -"contacts.detail.avatar.crop.cancel" = "Cancel"; - /* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ "contacts.detail.avatar.crop.choose" = "Choose"; +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Profile picture crop"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Swipe up or down to zoom. Use the actions to move the photo."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Move up"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Move down"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Move left"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Move right"; + /* Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved */ "contacts.detail.avatar.savingAnnouncement" = "Saving photo"; diff --git a/MC1/Resources/Localization/es.lproj/Contacts.strings b/MC1/Resources/Localization/es.lproj/Contacts.strings index f9ce4d30c..e3e8c6b71 100644 --- a/MC1/Resources/Localization/es.lproj/Contacts.strings +++ b/MC1/Resources/Localization/es.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Ese archivo no se pudo usar como foto de perfil."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Mover y escalar"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Elegir"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Recorte de la foto de perfil"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Desliza hacia arriba o hacia abajo para hacer zoom. Usa las acciones para mover la foto."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Mover hacia arriba"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Mover hacia abajo"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Mover a la izquierda"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Mover a la derecha"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Apodo"; diff --git a/MC1/Resources/Localization/fr.lproj/Contacts.strings b/MC1/Resources/Localization/fr.lproj/Contacts.strings index 693a557d5..f86aa1efc 100644 --- a/MC1/Resources/Localization/fr.lproj/Contacts.strings +++ b/MC1/Resources/Localization/fr.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Ce fichier n'a pas pu être utilisé comme photo de profil."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Déplacer et recadrer"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Choisir"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Recadrage de la photo de profil"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Balayez vers le haut ou le bas pour zoomer. Utilisez les actions pour déplacer la photo."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Déplacer vers le haut"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Déplacer vers le bas"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Déplacer vers la gauche"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Déplacer vers la droite"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Surnom"; diff --git a/MC1/Resources/Localization/it.lproj/Contacts.strings b/MC1/Resources/Localization/it.lproj/Contacts.strings index efd50f55d..542b317ca 100644 --- a/MC1/Resources/Localization/it.lproj/Contacts.strings +++ b/MC1/Resources/Localization/it.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved */ "contacts.detail.avatar.savingAnnouncement" = "Salvataggio foto"; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Sposta e scala"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Scegli"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Ritaglio della foto del profilo"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Scorri verso l'alto o il basso per lo zoom. Usa le azioni per spostare la foto."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Sposta in alto"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Sposta in basso"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Sposta a sinistra"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Sposta a destra"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Nickname"; diff --git a/MC1/Resources/Localization/nl.lproj/Contacts.strings b/MC1/Resources/Localization/nl.lproj/Contacts.strings index 102c676aa..d901e13b9 100644 --- a/MC1/Resources/Localization/nl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/nl.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Dit bestand kan niet als profielfoto worden gebruikt."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Verplaatsen en schalen"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Kies"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Uitsnede van profielfoto"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Veeg omhoog of omlaag om te zoomen. Gebruik de handelingen om de foto te verplaatsen."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Omhoog verplaatsen"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Omlaag verplaatsen"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Naar links verplaatsen"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Naar rechts verplaatsen"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Bijnaam"; diff --git a/MC1/Resources/Localization/pl.lproj/Contacts.strings b/MC1/Resources/Localization/pl.lproj/Contacts.strings index 04a876876..f06bf8090 100644 --- a/MC1/Resources/Localization/pl.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pl.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Tego pliku nie można użyć jako zdjęcia profilowego."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Przesuń i skaluj"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Wybierz"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Kadrowanie zdjęcia profilowego"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Przesuń palcem w górę lub w dół, aby zmienić powiększenie. Użyj czynności, aby przesunąć zdjęcie."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Przesuń w górę"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Przesuń w dół"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Przesuń w lewo"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Przesuń w prawo"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Pseudonim"; diff --git a/MC1/Resources/Localization/ru.lproj/Contacts.strings b/MC1/Resources/Localization/ru.lproj/Contacts.strings index d3ba5ef49..b4d9df580 100644 --- a/MC1/Resources/Localization/ru.lproj/Contacts.strings +++ b/MC1/Resources/Localization/ru.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Этот файл нельзя использовать как фото профиля."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Переместить и масштабировать"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Выбрать"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Кадрирование фото профиля"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Смахните вверх или вниз, чтобы масштабировать. Используйте действия, чтобы переместить фото."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Переместить вверх"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Переместить вниз"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Переместить влево"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Переместить вправо"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Псевдоним"; diff --git a/MC1/Resources/Localization/uk.lproj/Contacts.strings b/MC1/Resources/Localization/uk.lproj/Contacts.strings index b9b309f8a..607bade73 100644 --- a/MC1/Resources/Localization/uk.lproj/Contacts.strings +++ b/MC1/Resources/Localization/uk.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "Цей файл не можна використати як фото профілю."; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Перемістити та масштабувати"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Вибрати"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Кадрування фото профілю"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Проведіть вгору або вниз, щоб змінити масштаб. Використовуйте дії, щоб перемістити фото."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Перемістити вгору"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Перемістити вниз"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Перемістити ліворуч"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Перемістити праворуч"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "Псевдонім"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings index 318a40207..91bf30f39 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -287,6 +287,30 @@ /* Location: ContactDetailView.swift - Purpose: Error shown when the picked file is not a valid image */ "contacts.detail.avatar.invalidImage" = "该文件无法用作个人头像。"; +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "移动和缩放"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "选择"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "裁剪个人头像"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "上下轻扫可缩放。使用操作可移动照片。"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "上移"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "下移"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "左移"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "右移"; + /* Location: ContactDetailView.swift - Purpose: Nickname label */ "contacts.detail.nickname" = "昵称"; diff --git a/MC1/Services/ImageURLDetector.swift b/MC1/Services/ImageURLDetector.swift index 5d8f69175..924b01049 100644 --- a/MC1/Services/ImageURLDetector.swift +++ b/MC1/Services/ImageURLDetector.swift @@ -10,7 +10,7 @@ enum ImageURLDetector { /// 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? { + static func downsampledImage(from data: Data, maxPixelSize: CGFloat = inlineMaxPixelSize) -> UIImage? { let options: [CFString: Any] = [ kCGImageSourceShouldCache: false ] @@ -19,7 +19,7 @@ enum ImageURLDetector { } let downsampleOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: inlineMaxPixelSize, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true ] diff --git a/MC1/Views/Components/AvatarCropGeometry.swift b/MC1/Views/Components/AvatarCropGeometry.swift new file mode 100644 index 000000000..c63d725ea --- /dev/null +++ b/MC1/Views/Components/AvatarCropGeometry.swift @@ -0,0 +1,89 @@ +import CoreGraphics + +/// Pan, zoom, and crop-guide math for `AvatarCropView`. +struct AvatarCropGeometry: Equatable { + static let minScale: CGFloat = 1 + static let maxScale: CGFloat = 4 + static let compactMaxCropSize: CGFloat = 300 + static let regularMaxCropSize: CGFloat = 480 + static let cropMargin: CGFloat = 32 + static let outputSide: CGFloat = 512 + static let decodeMaxPixelSize: CGFloat = 1024 + static let zoomStep: CGFloat = 0.25 + static let panStepFraction: CGFloat = 0.125 + + var cropSize: CGFloat + var imageSize: CGSize + var scale: CGFloat = 1 + var offset: CGSize = .zero + + static func cropSize(in available: CGSize, isRegularWidth: Bool) -> CGFloat { + let maxSide = isRegularWidth ? regularMaxCropSize : compactMaxCropSize + let shortest = min(available.width, available.height) + let fitted = min(maxSide, shortest - cropMargin * 2) + return max(fitted, 1) + } + + var baseDisplaySize: CGSize { + guard imageSize.width > 0, imageSize.height > 0 else { + return CGSize(width: cropSize, height: cropSize) + } + let fillScale = max(cropSize / imageSize.width, cropSize / imageSize.height) + return CGSize(width: imageSize.width * fillScale, height: imageSize.height * fillScale) + } + + func clampedScale(_ proposed: CGFloat) -> CGFloat { + min(max(proposed, Self.minScale), Self.maxScale) + } + + /// Keeps the crop square fully covered at the given scale. + func clampedOffset(_ proposed: CGSize, scale: CGFloat? = nil) -> CGSize { + let usedScale = scale ?? self.scale + let displayed = CGSize( + width: baseDisplaySize.width * usedScale, + height: baseDisplaySize.height * usedScale + ) + let maxX = max(0, (displayed.width - cropSize) / 2) + let maxY = max(0, (displayed.height - cropSize) / 2) + return CGSize( + width: min(max(proposed.width, -maxX), maxX), + height: min(max(proposed.height, -maxY), maxY) + ) + } + + func liveTransform(pinchDelta: CGFloat, dragTranslation: CGSize) -> (scale: CGFloat, offset: CGSize) { + let liveScale = clampedScale(scale * pinchDelta) + let proposed = CGSize( + width: offset.width + dragTranslation.width, + height: offset.height + dragTranslation.height + ) + return (liveScale, clampedOffset(proposed, scale: liveScale)) + } + + func imageDrawRect(outputSide: CGFloat) -> CGRect { + let displayed = CGSize( + width: baseDisplaySize.width * scale, + height: baseDisplaySize.height * scale + ) + let origin = CGPoint( + x: (cropSize - displayed.width) / 2 + offset.width, + y: (cropSize - displayed.height) / 2 + offset.height + ) + let renderScale = outputSide / cropSize + return CGRect( + x: origin.x * renderScale, + y: origin.y * renderScale, + width: displayed.width * renderScale, + height: displayed.height * renderScale + ) + } + + mutating func applyZoomStep(_ delta: CGFloat) { + scale = clampedScale(scale + delta) + offset = clampedOffset(offset) + } + + mutating func applyPan(width: CGFloat, height: CGFloat) { + offset = clampedOffset(CGSize(width: offset.width + width, height: offset.height + height)) + } +} diff --git a/MC1/Views/Components/AvatarCropView.swift b/MC1/Views/Components/AvatarCropView.swift index 6cf0e5cf3..1f59354aa 100644 --- a/MC1/Views/Components/AvatarCropView.swift +++ b/MC1/Views/Components/AvatarCropView.swift @@ -8,63 +8,111 @@ struct AvatarCropView: View { let onCancel: () -> Void let onComplete: (UIImage) -> Void - /// Side length, in points, of the square crop guide shown on screen. - private let cropSize: CGFloat = 300 - - /// Allowed zoom range, applied both live during a pinch and once it ends. - private let minScale: CGFloat = 1 - private let maxScale: CGFloat = 4 + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @GestureState private var dragTranslation: CGSize = .zero @GestureState private var pinchDelta: CGFloat = 1 - @State private var offset: CGSize = .zero - @State private var scale: CGFloat = 1 + @State private var geometry = AvatarCropGeometry( + cropSize: AvatarCropGeometry.compactMaxCropSize, + imageSize: .zero + ) + + private let guideLineWidth: CGFloat = 2 + private let dimOpacity = 0.5 var body: some View { NavigationStack { - ZStack { - Color.black.ignoresSafeArea() - - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: baseDisplaySize.width, height: baseDisplaySize.height) - .scaleEffect(clampedScale(scale * pinchDelta)) - .offset(x: offset.width + dragTranslation.width, y: offset.height + dragTranslation.height) - .frame(width: cropSize, height: cropSize) - .clipped() - .contentShape(Rectangle()) - .gesture(dragGesture) - .simultaneousGesture(magnificationGesture) - - Circle() - .strokeBorder(Color.white, lineWidth: 2) - .frame(width: cropSize, height: cropSize) - .allowsHitTesting(false) - - dimmingMask - .allowsHitTesting(false) + GeometryReader { proxy in + let cropSize = AvatarCropGeometry.cropSize( + in: proxy.size, + isRegularWidth: horizontalSizeClass == .regular + ) + canvas(cropSize: cropSize) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onChange(of: cropSize) { _, newSize in + applyCropSize(newSize) + } + .onAppear { + applyCropSize(cropSize) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.ignoresSafeArea()) .navigationTitle(L10n.Contacts.Contacts.Detail.Avatar.Crop.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(L10n.Contacts.Contacts.Detail.Avatar.Crop.cancel, action: onCancel) + Button(L10n.Contacts.Contacts.Common.cancel, action: onCancel) } ToolbarItem(placement: .confirmationAction) { Button(L10n.Contacts.Contacts.Detail.Avatar.Crop.choose) { - onComplete(croppedImage()) + confirmCrop() } } } + .toolbarBackground(.hidden, for: .navigationBar) + .tint(.white) + } + .onAppear { + geometry.imageSize = image.size + } + } + + private func canvas(cropSize: CGFloat) -> some View { + let resolved = resolvedGeometry(cropSize: cropSize) + let live = resolved.liveTransform(pinchDelta: pinchDelta, dragTranslation: dragTranslation) + let panStep = cropSize * AvatarCropGeometry.panStepFraction + + return ZStack { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: resolved.baseDisplaySize.width, height: resolved.baseDisplaySize.height) + .scaleEffect(live.scale) + .offset(x: live.offset.width, y: live.offset.height) + .allowsHitTesting(false) + + Circle() + .strokeBorder(Color.white, lineWidth: guideLineWidth) + .frame(width: cropSize, height: cropSize) + .allowsHitTesting(false) + + dimmingMask(cropSize: cropSize) + .allowsHitTesting(false) + } + .contentShape(Rectangle()) + .gesture(dragGesture(cropSize: cropSize)) + .simultaneousGesture(magnificationGesture(cropSize: cropSize)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.Contacts.Contacts.Detail.Avatar.Crop.preview) + .accessibilityHint(L10n.Contacts.Contacts.Detail.Avatar.Crop.previewHint) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + geometry.applyZoomStep(AvatarCropGeometry.zoomStep) + case .decrement: + geometry.applyZoomStep(-AvatarCropGeometry.zoomStep) + default: + break + } + } + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.Avatar.Crop.moveUp) { + geometry.applyPan(width: 0, height: -panStep) + } + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.Avatar.Crop.moveDown) { + geometry.applyPan(width: 0, height: panStep) + } + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.Avatar.Crop.moveLeft) { + geometry.applyPan(width: -panStep, height: 0) + } + .accessibilityAction(named: L10n.Contacts.Contacts.Detail.Avatar.Crop.moveRight) { + geometry.applyPan(width: panStep, height: 0) } } /// A full-bleed dark scrim with a circular window cut out over the crop guide, /// drawn with an even-odd fill so the two shapes combine into a single hole-punched path. - private var dimmingMask: some View { + private func dimmingMask(cropSize: CGFloat) -> some View { GeometryReader { proxy in Path { path in path.addRect(CGRect(origin: .zero, size: proxy.size)) @@ -76,82 +124,72 @@ struct AvatarCropView: View { ) path.addEllipse(in: circleRect) } - .fill(Color.black.opacity(0.5), style: FillStyle(eoFill: true)) + .fill(Color.black.opacity(dimOpacity), style: FillStyle(eoFill: true)) } } - /// The image's size, in points, when scaled (via `.scaledToFill`) to just cover the crop square. - private var baseDisplaySize: CGSize { - let imageSize = image.size - guard imageSize.width > 0, imageSize.height > 0 else { return CGSize(width: cropSize, height: cropSize) } - let fillScale = max(cropSize / imageSize.width, cropSize / imageSize.height) - return CGSize(width: imageSize.width * fillScale, height: imageSize.height * fillScale) - } - - private var dragGesture: some Gesture { + private func dragGesture(cropSize: CGFloat) -> some Gesture { DragGesture() .updating($dragTranslation) { value, state, _ in state = value.translation } .onEnded { value in - offset = clampedOffset( - CGSize(width: offset.width + value.translation.width, height: offset.height + value.translation.height) - ) + commitLiveTransform(cropSize: cropSize, pinchDelta: pinchDelta, dragTranslation: value.translation) } } - private var magnificationGesture: some Gesture { + private func magnificationGesture(cropSize: CGFloat) -> some Gesture { MagnifyGesture() .updating($pinchDelta) { value, state, _ in state = value.magnification } .onEnded { value in - scale = clampedScale(scale * value.magnification) - offset = clampedOffset(offset) + commitLiveTransform(cropSize: cropSize, pinchDelta: value.magnification, dragTranslation: dragTranslation) } } - private func clampedScale(_ proposed: CGFloat) -> CGFloat { - min(max(proposed, minScale), maxScale) - } - - /// Keeps the displayed image covering the crop square at all times, regardless of pan/zoom. - private func clampedOffset(_ proposed: CGSize) -> CGSize { - let displayedSize = CGSize(width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale) - let maxOffsetX = max(0, (displayedSize.width - cropSize) / 2) - let maxOffsetY = max(0, (displayedSize.height - cropSize) / 2) - return CGSize( - width: min(max(proposed.width, -maxOffsetX), maxOffsetX), - height: min(max(proposed.height, -maxOffsetY), maxOffsetY) - ) + private func confirmCrop() { + let live = resolvedGeometry(cropSize: geometry.cropSize) + .liveTransform(pinchDelta: pinchDelta, dragTranslation: dragTranslation) + var snapshot = resolvedGeometry(cropSize: geometry.cropSize) + snapshot.scale = live.scale + snapshot.offset = live.offset + onComplete(croppedImage(snapshot)) } - /// Renders the portion of the source image currently visible inside the crop guide. - /// - /// Draws via `UIImage.draw(in:)` rather than cropping `cgImage` directly, so that EXIF - /// orientation (e.g. a portrait photo shot with a rotated sensor) is honored exactly as - /// it is in the on-screen preview, which uses the same point-space geometry. - private func croppedImage() -> UIImage { - let outputSide: CGFloat = 1024 - let renderScale = outputSide / cropSize - - let displayedSize = CGSize(width: baseDisplaySize.width * scale, height: baseDisplaySize.height * scale) - let imageOrigin = CGPoint( - x: (cropSize - displayedSize.width) / 2 + offset.width, - y: (cropSize - displayedSize.height) / 2 + offset.height - ) - + /// Draws via `UIImage.draw(in:)` so EXIF orientation matches the on-screen preview. + private func croppedImage(_ snapshot: AvatarCropGeometry) -> UIImage { + let outputSide = AvatarCropGeometry.outputSide let format = UIGraphicsImageRendererFormat() format.scale = 1 - let renderer = UIGraphicsImageRenderer(size: CGSize(width: outputSide, height: outputSide), format: format) + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: outputSide, height: outputSide), + format: format + ) return renderer.image { _ in - let drawRect = CGRect( - x: imageOrigin.x * renderScale, - y: imageOrigin.y * renderScale, - width: displayedSize.width * renderScale, - height: displayedSize.height * renderScale - ) - image.draw(in: drawRect) + image.draw(in: snapshot.imageDrawRect(outputSide: outputSide)) } } + + private func resolvedGeometry(cropSize: CGFloat) -> AvatarCropGeometry { + var resolved = geometry + resolved.cropSize = cropSize + resolved.imageSize = image.size + return resolved + } + + private func commitLiveTransform(cropSize: CGFloat, pinchDelta: CGFloat, dragTranslation: CGSize) { + let live = resolvedGeometry(cropSize: cropSize) + .liveTransform(pinchDelta: pinchDelta, dragTranslation: dragTranslation) + geometry.cropSize = cropSize + geometry.imageSize = image.size + geometry.scale = live.scale + geometry.offset = live.offset + } + + private func applyCropSize(_ cropSize: CGFloat) { + geometry.cropSize = cropSize + geometry.imageSize = image.size + geometry.offset = geometry.clampedOffset(geometry.offset) + } } diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index 7c330106c..d5048af8f 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -138,7 +138,10 @@ struct ContactDetailView: View { contactTypeLabel: contactTypeLabel, measuredHeight: $headerHeight, isSavingAvatar: isSavingAvatar, - onEditAvatar: { showAvatarSourceMenu = true } + showAvatarSourceMenu: $showAvatarSourceMenu, + onChooseAvatarPhoto: { showAvatarPhotosPicker = true }, + onChooseAvatarFile: { showAvatarFileImporter = true }, + onRemoveAvatar: { Task { await removeAvatar() } } ) // Quick actions @@ -265,20 +268,6 @@ struct ContactDetailView: View { .onAppear { nickname = currentContact.nickname ?? "" } - .confirmationDialog( - L10n.Contacts.Contacts.Detail.Avatar.chooseSource, - isPresented: $showAvatarSourceMenu, - titleVisibility: .visible - ) { - Button(L10n.Contacts.Contacts.Detail.Avatar.choosePhoto) { showAvatarPhotosPicker = true } - Button(L10n.Contacts.Contacts.Detail.Avatar.chooseFile) { showAvatarFileImporter = true } - if currentContact.avatarImageData != nil { - Button(L10n.Contacts.Contacts.Detail.Avatar.removePhoto, role: .destructive) { - Task { await removeAvatar() } - } - } - Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} - } .photosPicker(isPresented: $showAvatarPhotosPicker, selection: $avatarPickerItem, matching: .images) .onChange(of: avatarPickerItem) { _, newItem in Task { await loadPickedAvatarPhoto(newItem) } @@ -583,21 +572,22 @@ struct ContactDetailView: View { } } - /// Decodes off the main actor and downsamples to a display-sized bound before the crop - /// screen ever sees the image, so a 12-48MP camera photo doesn't hitch the UI or hold - /// its full-resolution bitmap in memory while cropping. + /// Decodes off the main actor and downsamples before crop, so a camera photo + /// cannot hitch the UI or hold a full-resolution bitmap. private func presentCropSheet(data: Data) async { let decoded = await Task.detached(priority: .userInitiated) { - Self.downsampledImage(data: data, maxPixelSize: Self.cropMaxPixelSize) + ImageURLDetector.downsampledImage( + from: data, + maxPixelSize: AvatarCropGeometry.decodeMaxPixelSize + ) }.value guard let image = decoded else { errorMessage = L10n.Contacts.Contacts.Detail.Avatar.invalidImage return } pendingCropImage = image - // The picker/importer sheet may still be animating its dismissal; onChange above - // presents the pending image once it reports fully closed. If it's already closed - // by the time decoding finishes, present immediately. + // Both sheets already closed: present now. Otherwise `presentPendingCropIfReady` + // runs when `showAvatarPhotosPicker` or `showAvatarFileImporter` becomes false. if !showAvatarPhotosPicker, !showAvatarFileImporter { presentPendingCropIfReady() } @@ -642,33 +632,17 @@ struct ContactDetailView: View { isSavingAvatar = false } - /// Max pixel dimension the crop screen decodes and displays at; well above the 300pt - /// on-screen guide to stay sharp under the pinch zoom's 4x cap, but bounded so a raw - /// camera photo can't hold its full-resolution bitmap in memory while cropping. - private nonisolated static let cropMaxPixelSize: CGFloat = 1024 - - /// Decodes and downsamples via ImageIO instead of `UIImage(data:)`, so a large source - /// image is never fully decoded into memory. Mirrors `ImageURLDetector.downsampledImage(from:)`. - private nonisolated static func downsampledImage(data: Data, maxPixelSize: CGFloat) -> UIImage? { - let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary - guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil } - let downsampleOptions: [CFString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceShouldCacheImmediately: true - ] - guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { - return nil - } - return UIImage(cgImage: cgImage) - } + private nonisolated static let avatarMaxDimension: CGFloat = 512 + private nonisolated static let avatarJPEGQuality: CGFloat = 0.8 - /// Downscales to a max 512pt dimension and re-encodes as JPEG so avatars stay small in the store. - /// `nonisolated` so it can run on a background thread via `Task.detached` in `saveAvatar`. + /// Downscales to `avatarMaxDimension` and re-encodes as JPEG so avatars stay small in the store. private nonisolated static func processAvatarImage(image: UIImage) -> Data? { - let maxDimension: CGFloat = 512 - let scale = min(1, maxDimension / max(image.size.width, image.size.height)) + let longest = max(image.size.width, image.size.height) + guard longest > 0 else { return nil } + if longest <= avatarMaxDimension { + return image.jpegData(compressionQuality: avatarJPEGQuality) + } + let scale = avatarMaxDimension / longest let targetSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) let format = UIGraphicsImageRendererFormat() format.scale = 1 @@ -676,7 +650,7 @@ struct ContactDetailView: View { let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: targetSize)) } - return resized.jpegData(compressionQuality: 0.8) + return resized.jpegData(compressionQuality: avatarJPEGQuality) } // MARK: - Helpers @@ -706,11 +680,16 @@ struct ContactDetailView: View { private struct ContactDetailAvatarView: View { let contact: ContactDTO let isSavingAvatar: Bool - let onEditAvatar: () -> Void + @Binding var showSourceMenu: Bool + let onChoosePhoto: () -> Void + let onChooseFile: () -> Void + let onRemovePhoto: () -> Void var body: some View { if contact.type == .chat { - Button(action: onEditAvatar) { + Button { + showSourceMenu = true + } label: { ZStack { ContactAvatar(contact: contact, size: 150) if isSavingAvatar { @@ -726,6 +705,18 @@ private struct ContactDetailAvatarView: View { .buttonStyle(.plain) .disabled(isSavingAvatar) .accessibilityLabel(accessibilityLabel) + .confirmationDialog( + L10n.Contacts.Contacts.Detail.Avatar.chooseSource, + isPresented: $showSourceMenu, + titleVisibility: .visible + ) { + Button(L10n.Contacts.Contacts.Detail.Avatar.choosePhoto, action: onChoosePhoto) + Button(L10n.Contacts.Contacts.Detail.Avatar.chooseFile, action: onChooseFile) + if contact.avatarImageData != nil { + Button(L10n.Contacts.Contacts.Detail.Avatar.removePhoto, role: .destructive, action: onRemovePhoto) + } + Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} + } } else { switch contact.type { case .repeater: @@ -765,7 +756,10 @@ private struct ContactProfileSection: View { let contactTypeLabel: String @Binding var measuredHeight: CGFloat let isSavingAvatar: Bool - let onEditAvatar: () -> Void + @Binding var showAvatarSourceMenu: Bool + let onChooseAvatarPhoto: () -> Void + let onChooseAvatarFile: () -> Void + let onRemoveAvatar: () -> Void var body: some View { Section { @@ -773,7 +767,10 @@ private struct ContactProfileSection: View { ContactDetailAvatarView( contact: currentContact, isSavingAvatar: isSavingAvatar, - onEditAvatar: onEditAvatar + showSourceMenu: $showAvatarSourceMenu, + onChoosePhoto: onChooseAvatarPhoto, + onChooseFile: onChooseAvatarFile, + onRemovePhoto: onRemoveAvatar ) VStack(spacing: 4) { diff --git a/MC1Services/Sources/MC1Services/Services/ContactService.swift b/MC1Services/Sources/MC1Services/Services/ContactService.swift index e9307b935..8c108ace5 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactService.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactService.swift @@ -645,6 +645,7 @@ public actor ContactService { } try await dataStore.saveContact(existing.with(avatarImageData: imageData)) + await syncCoordinator?.notifyContactsChanged() } // MARK: - Device Favorite Sync diff --git a/MC1Tests/Services/ImageURLDetectorTests.swift b/MC1Tests/Services/ImageURLDetectorTests.swift index 14dade189..78174c460 100644 --- a/MC1Tests/Services/ImageURLDetectorTests.swift +++ b/MC1Tests/Services/ImageURLDetectorTests.swift @@ -2,6 +2,7 @@ import Foundation @testable import MC1 @testable import MC1Services import Testing +import UIKit struct ImageURLDetectorTests { // MARK: - Direct Image URL Detection @@ -76,6 +77,23 @@ struct ImageURLDetectorTests { #expect(!ImageURLDetector.isGIFData(Data())) } + // MARK: - Downsample + + @Test + func `downsampledImage honors maxPixelSize`() throws { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64), format: format) + let source = renderer.image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 64, height: 64)) + } + let data = try #require(source.pngData()) + let downsampled = ImageURLDetector.downsampledImage(from: data, maxPixelSize: 32) + let image = try #require(downsampled) + #expect(max(image.size.width, image.size.height) <= 32) + } + // MARK: - Giphy URL Resolution @Test diff --git a/MC1Tests/Views/Components/AvatarCropGeometryTests.swift b/MC1Tests/Views/Components/AvatarCropGeometryTests.swift new file mode 100644 index 000000000..db81e7459 --- /dev/null +++ b/MC1Tests/Views/Components/AvatarCropGeometryTests.swift @@ -0,0 +1,88 @@ +import Foundation +@testable import MC1 +import Testing + +@Suite("AvatarCropGeometry Tests") +struct AvatarCropGeometryTests { + @Test + func `fill size covers the crop square for a landscape image`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: CGSize(width: 200, height: 100)) + #expect(geometry.baseDisplaySize == CGSize(width: 200, height: 100)) + } + + @Test + func `fill size covers the crop square for a portrait image`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: CGSize(width: 100, height: 200)) + #expect(geometry.baseDisplaySize == CGSize(width: 100, height: 200)) + } + + @Test + func `zero image size falls back to the crop square`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: .zero) + #expect(geometry.baseDisplaySize == CGSize(width: 100, height: 100)) + } + + @Test + func `scale clamps to 1...4`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: CGSize(width: 100, height: 100)) + #expect(geometry.clampedScale(0.5) == 1) + #expect(geometry.clampedScale(1) == 1) + #expect(geometry.clampedScale(2) == 2) + #expect(geometry.clampedScale(8) == 4) + } + + @Test + func `offset clamps so the crop square stays covered`() { + let geometry = AvatarCropGeometry( + cropSize: 100, + imageSize: CGSize(width: 100, height: 100), + scale: 2, + offset: .zero + ) + let clamped = geometry.clampedOffset(CGSize(width: 1000, height: -1000)) + #expect(clamped.width == 50) + #expect(clamped.height == -50) + } + + @Test + func `live pinch reclamps offset to the live scale`() { + let geometry = AvatarCropGeometry( + cropSize: 100, + imageSize: CGSize(width: 100, height: 100), + scale: 4, + offset: CGSize(width: 150, height: 0) + ) + let live = geometry.liveTransform(pinchDelta: 0.5, dragTranslation: .zero) + #expect(live.scale == 2) + #expect(live.offset.width == 50) + #expect(live.offset.height == 0) + } + + @Test + func `draw rect at scale 1 offset 0 matches the centered fill`() { + let geometry = AvatarCropGeometry(cropSize: 100, imageSize: CGSize(width: 200, height: 100)) + let rect = geometry.imageDrawRect(outputSide: 512) + #expect(abs(rect.origin.x - -256) < 0.001) + #expect(abs(rect.origin.y - 0) < 0.001) + #expect(abs(rect.width - 1024) < 0.001) + #expect(abs(rect.height - 512) < 0.001) + } + + @Test + func `crop size shrinks on a short landscape canvas`() { + let size = AvatarCropGeometry.cropSize( + in: CGSize(width: 667, height: 280), + isRegularWidth: false + ) + #expect(size == 280 - (AvatarCropGeometry.cropMargin * 2)) + } + + @Test + func `crop size uses the regular cap on iPad`() { + let size = AvatarCropGeometry.cropSize( + in: CGSize(width: 1024, height: 768), + isRegularWidth: true + ) + #expect(size == AvatarCropGeometry.regularMaxCropSize) + } +} From 69e6a2a179bd94c0c3aa4448541247a78513bec9 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:24:10 -0700 Subject: [PATCH 20/47] fix(contacts): refresh list after nickname edit - Notify contacts changed so the Nodes list updates nickname and block --- MC1Services/Sources/MC1Services/Services/ContactService.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MC1Services/Sources/MC1Services/Services/ContactService.swift b/MC1Services/Sources/MC1Services/Services/ContactService.swift index 8c108ace5..61a212eb4 100644 --- a/MC1Services/Sources/MC1Services/Services/ContactService.swift +++ b/MC1Services/Sources/MC1Services/Services/ContactService.swift @@ -588,6 +588,8 @@ public actor ContactService { } else if isBeingUnblocked { await cleanupCoordinator?.handleCleanup(contactID: contactID, reason: .unblocked, publicKey: existing.publicKey) } + + await syncCoordinator?.notifyContactsChanged() } /// Updates OCV settings for a contact From a3dfe0764529f8d80bfdbaae5586a3e5feebcfe1 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:24:44 -0700 Subject: [PATCH 21/47] feat(chats): show avatars on incoming clusters - Last incoming in a channel cluster gets a photo when the sender uniquely matches a contact, otherwise initials - Rooms use the same layout with initials; DMs skip the column --- MC1/State/ChatTimelinePrimer.swift | 21 +- .../Components/IncomingAvatarGutter.swift | 27 ++ .../Components/IncomingAvatarJPEGStore.swift | 41 ++ .../IncomingBubbleAvatarMetrics.swift | 9 + .../MessageBubbleConfiguration.swift | 33 +- .../Components/UnifiedMessageBubble.swift | 66 ++- .../Chats/Timeline/ChatTimeline+Bake.swift | 16 +- .../Chats/Timeline/ChatTimeline+Paging.swift | 42 +- .../ChatMessageBakeState+ItemBuild.swift | 59 ++- .../Chats/ViewModel/ChatSenderTables.swift | 8 +- .../ViewModel/ChatTimelinePopulator.swift | 4 +- .../ViewModel/ChatViewModel+ItemBuild.swift | 7 +- .../ViewModel/ChatViewModel+Messages.swift | 46 ++ MC1/Views/Chats/ViewModel/ChatViewModel.swift | 13 +- MC1/Views/Components/ContactAvatar.swift | 6 +- .../Rooms/RoomConversationView.swift | 38 +- .../Rooms/RoomConversationViewModel.swift | 37 ++ .../RemoteNodes/Rooms/RoomMessageBubble.swift | 98 ++++- .../RemoteNodes/Rooms/RoomTiledRow.swift | 15 + .../MC1Services/Models/MessageEnvelope.swift | 24 +- .../Rendering/IncomingAvatarIdentity.swift | 41 ++ .../Models/Rendering/MessageBuildInputs.swift | 6 +- .../Models/Rendering/MessageItem.swift | 9 +- .../Services/ChatCoordinator+Mutations.swift | 3 +- .../Services/MessageFragmentBuilder.swift | 3 +- .../State/ChatTimelineFreshnessTests.swift | 81 +++- MC1Tests/State/ChatTimelinePrimerTests.swift | 17 +- .../ChatViewModelPreviewSeedTests.swift | 8 +- MC1Tests/ViewModels/ChatViewModelTests.swift | 114 ++--- .../IncomingAvatarClusterTests.swift | 401 ++++++++++++++++++ .../IncomingAvatarIdentityTests.swift | 72 ++++ ...omConversationViewModelOrderingTests.swift | 125 ++++++ .../IncomingAvatarJPEGStoreTests.swift | 67 +++ ...IsolatedIncomingAvatarJPEGStoreTrait.swift | 27 ++ .../Components/MessageStatusTextTests.swift | 3 +- .../RoomMessageBubbleA11yLabelTests.swift | 147 +++++++ .../UnifiedMessageBubbleA11yLabelTests.swift | 6 +- .../MessageBubbleConfigurationTests.swift | 71 +++- .../Chats/Models/ChatRenderStateTests.swift | 3 +- 39 files changed, 1648 insertions(+), 166 deletions(-) create mode 100644 MC1/Views/Chats/Components/IncomingAvatarGutter.swift create mode 100644 MC1/Views/Chats/Components/IncomingAvatarJPEGStore.swift create mode 100644 MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift create mode 100644 MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift create mode 100644 MC1Services/Sources/MC1Services/Models/Rendering/IncomingAvatarIdentity.swift create mode 100644 MC1Tests/ViewModels/IncomingAvatarClusterTests.swift create mode 100644 MC1Tests/ViewModels/IncomingAvatarIdentityTests.swift create mode 100644 MC1Tests/Views/Chats/Components/IncomingAvatarJPEGStoreTests.swift create mode 100644 MC1Tests/Views/Chats/Components/IsolatedIncomingAvatarJPEGStoreTrait.swift create mode 100644 MC1Tests/Views/Chats/Components/RoomMessageBubbleA11yLabelTests.swift diff --git a/MC1/State/ChatTimelinePrimer.swift b/MC1/State/ChatTimelinePrimer.swift index 624209233..9b1356b86 100644 --- a/MC1/State/ChatTimelinePrimer.swift +++ b/MC1/State/ChatTimelinePrimer.swift @@ -76,7 +76,7 @@ final class ChatTimelinePrimer { // DM bubbles never show the sender row. senderTables = .empty case let .channel(channel): - // Contacts before the bake so `senderResolutionFor` resolves names. + // Contacts before bake so `senderResolutionFor` and `IncomingAvatarJPEGStore` see the table. senderTables = await fetchSenderTables(radioID: channel.radioID) } @@ -123,10 +123,20 @@ final class ChatTimelinePrimer { guard let dataStore = dependencies.dataStore() else { return .empty } do { let contacts = try await dataStore.fetchContacts(radioID: radioID) + try Task.checkCancellation() + let incomingAvatars = await incomingAvatarIdentitiesOffMain(from: contacts) + IncomingAvatarJPEGStore.replace( + contacts: contacts, + identities: Array(incomingAvatars.values) + ) + if Task.isCancelled { return .empty } return ChatSenderTables( contacts: contacts, - nicknamesByLoweredName: MessageBubbleConfiguration.buildNicknameLookup(from: contacts) + nicknamesByLoweredName: MessageBubbleConfiguration.buildNicknameLookup(from: contacts), + incomingAvatars: incomingAvatars ) + } catch is CancellationError { + return .empty } catch { logger.warning("Failed to load contacts for channel prime: \(error.localizedDescription)") return .empty @@ -160,3 +170,10 @@ final class ChatTimelinePrimer { } } } + +@concurrent +private func incomingAvatarIdentitiesOffMain( + from contacts: [ContactDTO] +) async -> [String: IncomingAvatarIdentity] { + MessageBubbleConfiguration.incomingAvatarIdentities(from: contacts) +} diff --git a/MC1/Views/Chats/Components/IncomingAvatarGutter.swift b/MC1/Views/Chats/Components/IncomingAvatarGutter.swift new file mode 100644 index 000000000..916ff012e --- /dev/null +++ b/MC1/Views/Chats/Components/IncomingAvatarGutter.swift @@ -0,0 +1,27 @@ +import MC1Services +import SwiftUI + +struct IncomingAvatarGutter: View { + let identity: IncomingAvatarIdentity? + let reserveColumn: Bool + @ViewBuilder var content: Content + + var body: some View { + if let identity { + HStack(alignment: .bottom, spacing: IncomingBubbleAvatarMetrics.gap) { + ContactAvatar( + name: identity.name, + size: IncomingBubbleAvatarMetrics.size, + imageData: IncomingAvatarJPEGStore.data(for: identity.matchedContactID) + ) + .accessibilityHidden(true) + content + } + .contentShape(.rect) + } else if reserveColumn { + content.padding(.leading, IncomingBubbleAvatarMetrics.columnWidth) + } else { + content + } + } +} diff --git a/MC1/Views/Chats/Components/IncomingAvatarJPEGStore.swift b/MC1/Views/Chats/Components/IncomingAvatarJPEGStore.swift new file mode 100644 index 000000000..41f5dd946 --- /dev/null +++ b/MC1/Views/Chats/Components/IncomingAvatarJPEGStore.swift @@ -0,0 +1,41 @@ +import Foundation +import MC1Services + +/// Process-lifetime JPEG bytes for channel cluster-end photos. Replace-all +/// before bake or patch so first paint never hits an empty map with a live revision. +/// Tests bind `isolatedStorage` so parallel suites do not share this map. +@MainActor +enum IncomingAvatarJPEGStore { + @MainActor + final class Storage { + var jpegByContactID: [UUID: Data] = [:] + } + + @TaskLocal static var isolatedStorage: Storage? + + private static let processStorage = Storage() + private static var storage: Storage { + isolatedStorage ?? processStorage + } + + static func replace(contacts: [ContactDTO], identities: [IncomingAvatarIdentity]) { + let jpegByID: [UUID: Data] = Dictionary( + uniqueKeysWithValues: contacts.compactMap { contact in + guard let data = contact.avatarImageData, !data.isEmpty else { return nil } + return (contact.id, data) + } + ) + var next: [UUID: Data] = [:] + for identity in identities { + guard let contactID = identity.matchedContactID, + let data = jpegByID[contactID] else { continue } + next[contactID] = data + } + storage.jpegByContactID = next + } + + static func data(for contactID: UUID?) -> Data? { + guard let contactID else { return nil } + return storage.jpegByContactID[contactID] + } +} diff --git a/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift b/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift new file mode 100644 index 000000000..b54faca78 --- /dev/null +++ b/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift @@ -0,0 +1,9 @@ +import CoreGraphics + +enum IncomingBubbleAvatarMetrics { + static let size: CGFloat = 28 + static let gap: CGFloat = 6 + static var columnWidth: CGFloat { + size + gap + } +} diff --git a/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift b/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift index 107fdac52..0aabe1102 100644 --- a/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift +++ b/MC1/Views/Chats/Components/MessageBubbleConfiguration.swift @@ -3,11 +3,15 @@ import MC1Services /// View-layer formatting flags for a message bubble. struct MessageBubbleConfiguration { var showSenderName: Bool + var showsIncomingAvatars: Bool - static let directMessage = MessageBubbleConfiguration(showSenderName: false) + static let directMessage = MessageBubbleConfiguration( + showSenderName: false, + showsIncomingAvatars: false + ) static func channel(isPublic: Bool) -> MessageBubbleConfiguration { - MessageBubbleConfiguration(showSenderName: true) + MessageBubbleConfiguration(showSenderName: true, showsIncomingAvatars: true) } /// Builds a `loweredName -> nickname` lookup for channel sender matching. @@ -31,9 +35,30 @@ struct MessageBubbleConfiguration { return lookup } + /// Unique lowered `contact.name` → avatar identity. Unlike `buildNicknameLookup`, + /// a nickname is not required. Colliding names are omitted. + nonisolated static func incomingAvatarIdentities( + from contacts: [ContactDTO] + ) -> [String: IncomingAvatarIdentity] { + var counts: [String: Int] = [:] + for contact in contacts { + counts[contact.name.lowercased(), default: 0] += 1 + } + var lookup: [String: IncomingAvatarIdentity] = [:] + for contact in contacts { + let key = contact.name.lowercased() + guard counts[key] == 1 else { continue } + lookup[key] = IncomingAvatarIdentity( + name: contact.name, + matchedContactID: contact.id, + imageRevision: IncomingAvatarIdentity.revision(of: contact.avatarImageData) + ) + } + 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. + /// Baked into `MessageItem.envelope.senderResolution` upstream of the bubble. static func resolveSenderName( for message: MessageDTO, contacts: [ContactDTO], diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index 74d92cae0..be8cfc5a8 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -97,33 +97,14 @@ struct UnifiedMessageBubble: View, Equatable { item.grouping.showSenderName { SenderNameLabel(resolution: item.envelope.senderResolution, nameColor: senderColor) .senderNamePlacement(enclosingStackSpacing: bubbleStackSpacing) + .padding(.leading, reserveAvatarColumn ? IncomingBubbleAvatarMetrics.columnWidth : 0) } - bubbleActionsLongPress( - BubbleFragmentStack( - item: item, - layout: layout, - bubbleColor: resolvedBubbleColor, - timeColor: footerTimeColor, - 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 - ) - ) + bubbleActionsLongPress(bubbleWithOptionalAvatar) ForEach(Array(layout.siblings.enumerated()), id: \.offset) { _, fragment in siblingFragmentView(fragment) + .padding(.leading, reserveAvatarColumn ? IncomingBubbleAvatarMetrics.columnWidth : 0) } } .accessibilityElement(children: .combine) @@ -260,6 +241,47 @@ struct UnifiedMessageBubble: View, Equatable { } } + /// Channel incoming only. DM incoming and every outgoing row skip the column. + private var reserveAvatarColumn: Bool { + configuration.showsIncomingAvatars && !item.envelope.isOutgoing + } + + private var showAvatar: Bool { + reserveAvatarColumn && item.envelope.incomingAvatar != nil + } + + private var bubbleWithOptionalAvatar: some View { + IncomingAvatarGutter( + identity: showAvatar ? item.envelope.incomingAvatar : nil, + reserveColumn: reserveAvatarColumn + ) { + stackedBubble + } + } + + private var stackedBubble: some View { + BubbleFragmentStack( + item: item, + layout: layout, + bubbleColor: resolvedBubbleColor, + timeColor: footerTimeColor, + 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 + ) + } + // MARK: - Computed Properties private var resolvedBubbleColor: Color { diff --git a/MC1/Views/Chats/Timeline/ChatTimeline+Bake.swift b/MC1/Views/Chats/Timeline/ChatTimeline+Bake.swift index 6642d7273..fcd9409b8 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline+Bake.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline+Bake.swift @@ -22,17 +22,16 @@ extension ChatTimeline { /// message state. No-ops when the message is no longer present. func rebakeRow(_ messageID: UUID) { guard let coordinator, let writer else { return } - guard let message = coordinator.messagesByID[messageID] else { + let messages = coordinator.messages + guard let index = messages.firstIndex(where: { $0.id == messageID }) else { logger.warning("rebake requested for missing message id \(messageID)") return } - let previous: MessageDTO? = { - guard let index = coordinator.messages.firstIndex(where: { $0.id == messageID }), - index > 0 else { return nil } - return coordinator.messages[index - 1] - }() + let message = messages[index] + let previous: MessageDTO? = index > 0 ? messages[index - 1] : nil + let next: MessageDTO? = index + 1 < messages.count ? messages[index + 1] : nil writer.updateRenderItem(id: messageID) { _ in - makeItem(for: message, previous: previous) + makeItem(for: message, previous: previous, next: next) } } @@ -40,12 +39,13 @@ extension ChatTimeline { /// and decoded-cache rehydration run synchronously inside /// `makeBuildInputs`, so the returned item already carries its preview /// fragment at a stable height. - func makeItem(for message: MessageDTO, previous: MessageDTO?) -> MessageItem { + func makeItem(for message: MessageDTO, previous: MessageDTO?, next: MessageDTO?) -> MessageItem { MessageFragmentBuilder.makeItem( for: message, inputs: bake.makeBuildInputs( for: message, previous: previous, + next: next, envInputs: envInputs, senderTables: senderTablesProvider() ), diff --git a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift index 6f3c38ec5..0391ea94b 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift @@ -60,7 +60,7 @@ extension ChatTimeline { dataStore: dataStoreProvider(), bake: bake, envInputs: envInputs, - senderTables: senderTablesProvider(), + senderTables: senderTablesProvider, postApply: postApply, anchorSortDate: anchorSortDate ) @@ -198,7 +198,19 @@ extension ChatTimeline { guard coordinator != nil, let writer else { return false } let previous = messages.last guard writer.append(message) else { return false } - writer.appendRenderItem(makeItem(for: message, previous: previous)) + let newItem = makeItem(for: message, previous: previous, next: nil) + let shouldHandoff = previous.map { + ChatMessageBakeState.incomingClusterContinues(from: $0, to: message) + } ?? false + writer.updateRenderState { state in + var next = state + if shouldHandoff, let previous { + next = next.updatingItem(id: previous.id) { item in + item.with(envelope: item.envelope.with(incomingAvatar: nil)) + } + } + return next.appendingItem(newItem) + } return true } @@ -230,10 +242,30 @@ extension ChatTimeline { writer?.enqueueReload(updatedMessageIDs: updatedMessageIDs) } - /// Removes a message and its render item together. + /// Removes a message and rebakes remaining channel neighbors in one items update. func removeMessage(_ messageID: UUID) { - writer?.remove(messageID: messageID) - writer?.removeRenderItem(id: messageID) + guard let writer, let coordinator else { return } + let messages = coordinator.messages + guard let index = messages.firstIndex(where: { $0.id == messageID }) else { return } + let previous = index > 0 ? messages[index - 1] : nil + let nextDTO = index + 1 < messages.count ? messages[index + 1] : nil + let prevPrev = index > 1 ? messages[index - 2] : nil + let nextNext = index + 2 < messages.count ? messages[index + 2] : nil + writer.remove(messageID: messageID) + writer.updateRenderState { state in + var next = state.removingItem(id: messageID) + if let previous, previous.isChannelMessage { + next = next.updatingItem(id: previous.id) { _ in + makeItem(for: previous, previous: prevPrev, next: nextDTO) + } + } + if let nextDTO, nextDTO.isChannelMessage { + next = next.updatingItem(id: nextDTO.id) { _ in + makeItem(for: nextDTO, previous: previous, next: nextNext) + } + } + return next + } } /// Updates a loaded message in place and rebakes its row. No-ops when diff --git a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift index 2fe4894c3..7c0eb6103 100644 --- a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift @@ -20,13 +20,39 @@ extension ChatMessageBakeState { let showDirectionGap: Bool let showSenderName: Bool let showDayDivider: Bool + let isClusterEnd: Bool + } + + /// Channel-incoming only. Nil names break; empty string vs empty string continues. + static func incomingClusterContinues(from earlier: MessageDTO, to later: MessageDTO) -> Bool { + guard earlier.isChannelMessage, later.isChannelMessage, + !earlier.isOutgoing, !later.isOutgoing else { return false } + let timeGap = abs(Int(later.senderDate.timeIntervalSince(earlier.senderDate))) + guard timeGap <= messageGroupingGapSeconds else { return false } + guard let currentName = later.senderNodeName, + let previousName = earlier.senderNodeName else { return false } + return currentName == previousName } /// Computes all display flags in a single pass to avoid redundant message lookups. /// Used by `bakeAll` for O(n) performance instead of O(3n). - static func computeDisplayFlags(for message: MessageDTO, previous: MessageDTO?) -> DisplayFlags { + static func computeDisplayFlags( + for message: MessageDTO, + previous: MessageDTO?, + next: MessageDTO? + ) -> DisplayFlags { + let continuesToNext = next.map { incomingClusterContinues(from: message, to: $0) } ?? false + let isClusterEnd = + message.isChannelMessage && !message.isOutgoing && !continuesToNext + guard let previous else { - return DisplayFlags(showTimestamp: true, showDirectionGap: false, showSenderName: true, showDayDivider: true) + return DisplayFlags( + showTimestamp: true, + showDirectionGap: false, + showSenderName: true, + showDayDivider: true, + isClusterEnd: isClusterEnd + ) } // Keys on send time (senderDate), not the sortDate sort key. Under block-at-reconnect @@ -60,7 +86,13 @@ extension ChatMessageBakeState { true } - return DisplayFlags(showTimestamp: showTimestamp, showDirectionGap: showDirectionGap, showSenderName: showSenderName, showDayDivider: dayChanged) + return DisplayFlags( + showTimestamp: showTimestamp, + showDirectionGap: showDirectionGap, + showSenderName: showSenderName, + showDayDivider: dayChanged, + isClusterEnd: isClusterEnd + ) } // MARK: - Item Build @@ -76,11 +108,12 @@ extension ChatMessageBakeState { func makeBuildInputs( for message: MessageDTO, previous: MessageDTO?, + next: MessageDTO?, envInputs: EnvInputs, senderTables: ChatSenderTables ) -> MessageBuildInputs { seedPreviewStateIfNeeded(for: message, envInputs: envInputs) - let flags = Self.computeDisplayFlags(for: message, previous: previous) + let flags = Self.computeDisplayFlags(for: message, previous: previous, next: next) 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 @@ -138,6 +171,17 @@ extension ChatMessageBakeState { isMapPreviewReady = MapSnapshotStore.shared.isResolved(request) } + let senderResolution = senderResolutionFor(message, senderTables: senderTables) + let incomingAvatar: IncomingAvatarIdentity? = if flags.isClusterEnd { + IncomingAvatarIdentity.resolve( + senderNodeName: message.senderNodeName, + displayName: senderResolution.displayName, + table: senderTables.incomingAvatars + ) + } else { + nil + } + return MessageBuildInputs( messageID: message.id, previewState: previewStates[message.id] ?? .idle, @@ -158,12 +202,13 @@ extension ChatMessageBakeState { formattedPath: (envInputs.showIncomingPath && !message.isOutgoing) ? MessagePathFormatter.format(message) : nil, - senderResolution: senderResolutionFor(message, senderTables: senderTables), + senderResolution: senderResolution, showTimestamp: flags.showTimestamp, showDirectionGap: flags.showDirectionGap, showSenderName: flags.showSenderName, showNewMessagesDivider: message.id == newMessagesDividerMessageID, - showDayDivider: flags.showDayDivider + showDayDivider: flags.showDayDivider, + incomingAvatar: incomingAvatar ) } @@ -282,11 +327,13 @@ extension ChatMessageBakeState { // this loop already carrying its preview fragment at a stable height. let inputs: [(MessageDTO, MessageBuildInputs)] = messages.enumerated().map { index, message in let previous: MessageDTO? = index > 0 ? messages[index - 1] : nil + let next: MessageDTO? = index + 1 < messages.count ? messages[index + 1] : nil return ( message, makeBuildInputs( for: message, previous: previous, + next: next, envInputs: envInputs, senderTables: senderTables ) diff --git a/MC1/Views/Chats/ViewModel/ChatSenderTables.swift b/MC1/Views/Chats/ViewModel/ChatSenderTables.swift index 96f457d76..a7d93fbd6 100644 --- a/MC1/Views/Chats/ViewModel/ChatSenderTables.swift +++ b/MC1/Views/Chats/ViewModel/ChatSenderTables.swift @@ -6,6 +6,12 @@ import MC1Services struct ChatSenderTables: Equatable { let contacts: [ContactDTO] let nicknamesByLoweredName: [String: String] + /// Unique lowered contact name → channel incoming-avatar identity. + let incomingAvatars: [String: IncomingAvatarIdentity] - static let empty = ChatSenderTables(contacts: [], nicknamesByLoweredName: [:]) + static let empty = ChatSenderTables( + contacts: [], + nicknamesByLoweredName: [:], + incomingAvatars: [:] + ) } diff --git a/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift b/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift index 7bc933745..3197cc922 100644 --- a/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift +++ b/MC1/Views/Chats/ViewModel/ChatTimelinePopulator.swift @@ -32,7 +32,7 @@ enum ChatTimelinePopulator { dataStore: DataStore?, bake: ChatMessageBakeState, envInputs: EnvInputs, - senderTables: ChatSenderTables, + senderTables: @MainActor () -> ChatSenderTables, postApply: (@MainActor () -> Void)?, anchorSortDate: Date? ) async -> Outcome { @@ -101,7 +101,7 @@ enum ChatTimelinePopulator { messages: fetchedMessages, writer: writer, envInputs: envInputs, - senderTables: senderTables, + senderTables: senderTables(), postApply: postApply ) diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift index a0c8ffd4b..b7032551e 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ItemBuild.swift @@ -7,10 +7,15 @@ extension ChatViewModel { // MARK: - Item Build /// Assemble `MessageBuildInputs` from current bake and env state. - func makeBuildInputs(for message: MessageDTO, previous: MessageDTO?) -> MessageBuildInputs { + func makeBuildInputs( + for message: MessageDTO, + previous: MessageDTO?, + next: MessageDTO? + ) -> MessageBuildInputs { bake.makeBuildInputs( for: message, previous: previous, + next: next, envInputs: envInputs, senderTables: currentSenderTables() ) diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift index e1973d556..3577c79e2 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift @@ -8,13 +8,52 @@ extension ChatViewModel { func loadAllContacts(radioID: UUID) async { guard let dataStore else { return } + incomingAvatarLoadGeneration += 1 + let generation = incomingAvatarLoadGeneration + 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)") + return + } + + guard case .channel = timeline.conversation else { return } + guard !Task.isCancelled, generation == incomingAvatarLoadGeneration else { return } + let contacts = allContacts + let newMap = await incomingAvatarIdentitiesOffMain(from: contacts) + guard !Task.isCancelled, generation == incomingAvatarLoadGeneration else { return } + IncomingAvatarJPEGStore.replace(contacts: contacts, identities: Array(newMap.values)) + guard newMap != incomingAvatarIdentitiesByLoweredName else { return } + incomingAvatarIdentitiesByLoweredName = newMap + let didChange = patchIncomingAvatarRows(using: newMap) + if !didChange, timeline.writer != nil { + // Bump renderStateID so an in-flight applyRebuiltItems misses and + // rebakeAll reads live senderTablesProvider(). + timeline.writer?.updateRenderState { $0 } + } + } + + /// Rewrites cluster-end identities after a channel contact-table change. + @discardableResult + private func patchIncomingAvatarRows(using map: [String: IncomingAvatarIdentity]) -> Bool { + guard let writer = timeline.writer else { return false } + var didChange = false + for item in items where item.envelope.incomingAvatar != nil { + let identity = IncomingAvatarIdentity.resolve( + senderNodeName: timeline.messagesByID[item.id]?.senderNodeName, + displayName: item.envelope.senderName, + table: map + ) + guard item.envelope.incomingAvatar != identity else { continue } + writer.updateRenderItem(id: item.id) { item in + item.with(envelope: item.envelope.with(incomingAvatar: identity)) + } + didChange = true } + return didChange } // MARK: - Messages @@ -173,3 +212,10 @@ extension ChatViewModel { } } } + +@concurrent +private func incomingAvatarIdentitiesOffMain( + from contacts: [ContactDTO] +) async -> [String: IncomingAvatarIdentity] { + MessageBubbleConfiguration.incomingAvatarIdentities(from: contacts) +} diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel.swift b/MC1/Views/Chats/ViewModel/ChatViewModel.swift index 5e4bfdd55..56b44874f 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel.swift @@ -46,6 +46,13 @@ final class ChatViewModel { /// `allContacts` changes so per-message resolution stays O(1). var nicknamesByLoweredName: [String: String] = [:] + /// Unique lowered contact name → channel incoming-avatar identity. + var incomingAvatarIdentitiesByLoweredName: [String: IncomingAvatarIdentity] = [:] + + /// Bumped at the start of each `loadAllContacts` so a slower first fetch + /// cannot publish after a newer overlapping load. + @ObservationIgnored var incomingAvatarLoadGeneration = 0 + /// Synthetic contacts for channel senders not in contacts var channelSenders: [ContactDTO] = [] @@ -258,7 +265,11 @@ final class ChatViewModel { /// Snapshot of observed contact tables for the item bake. func currentSenderTables() -> ChatSenderTables { - ChatSenderTables(contacts: allContacts, nicknamesByLoweredName: nicknamesByLoweredName) + ChatSenderTables( + contacts: allContacts, + nicknamesByLoweredName: nicknamesByLoweredName, + incomingAvatars: incomingAvatarIdentitiesByLoweredName + ) } // MARK: - Dependencies diff --git a/MC1/Views/Components/ContactAvatar.swift b/MC1/Views/Components/ContactAvatar.swift index b096508dd..4e137b856 100644 --- a/MC1/Views/Components/ContactAvatar.swift +++ b/MC1/Views/Components/ContactAvatar.swift @@ -25,9 +25,13 @@ struct ContactAvatar: View { } init(name: String, size: CGFloat) { + self.init(name: name, size: size, imageData: nil) + } + + init(name: String, size: CGFloat, imageData: Data?) { self.name = name self.size = size - imageData = nil + self.imageData = imageData } var body: some View { diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index 90da05e69..28d18cae1 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -327,45 +327,47 @@ private struct MessagesView: View { } else if messages.isEmpty { EmptyMessagesView(session: session) } else { - let timestampVisibleIDs = Self.timestampVisibleIDs(in: messages) + let rows = RoomConversationViewModel.tiledRows(in: messages) ChatTiledView( - items: messages, - cellContent: { message in - messageBubble(for: message, showTimestamp: timestampVisibleIDs.contains(message.id)) - .environment(\.appTheme, theme) - .environment(\.openURL, openURL) + items: rows, + cellContent: { row in + messageBubble( + for: row.message, + showTimestamp: row.showTimestamp, + showSenderName: row.showSenderName, + showAvatar: row.showAvatar + ) + .environment(\.appTheme, theme) + .environment(\.openURL, openURL) }, contentBackground: theme.surfaces?.canvas, isAtBottom: $isAtBottom, unreadCount: $unreadCount, scrollToBottomRequest: scrollToBottomRequest, - countsTowardUnread: { !$0.isFromSelf } + countsTowardUnread: { !$0.message.isFromSelf } ) } } .themedCanvas(theme) } - private func messageBubble(for message: RoomMessageDTO, showTimestamp: Bool) -> some View { + private func messageBubble( + for message: RoomMessageDTO, + showTimestamp: Bool, + showSenderName: Bool, + showAvatar: Bool + ) -> some View { RoomMessageBubble( message: message, showTimestamp: showTimestamp, + showSenderName: showSenderName, + showAvatar: showAvatar, 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 - } } // MARK: - Empty Messages View diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift index 9c077ec3a..368e537ff 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift @@ -229,4 +229,41 @@ final class RoomConversationViewModel { let gap = abs(Int(currentMessage.timestamp) - Int(previousMessage.timestamp)) return gap > messageGroupingGapSeconds } + + /// Incoming rooms cluster on `authorKeyPrefix` plus `messageGroupingGapSeconds`. + /// Display-name matches do not merge prefixes. + static func incomingClusterContinues(from earlier: RoomMessageDTO, to later: RoomMessageDTO) -> Bool { + guard !earlier.isFromSelf, !later.isFromSelf else { return false } + let gap = abs(Int(later.timestamp) - Int(earlier.timestamp)) + guard gap <= messageGroupingGapSeconds else { return false } + return earlier.authorKeyPrefix == later.authorKeyPrefix + } + + /// Name on cluster-start, avatar on cluster-end. + static func incomingBookends(in messages: [RoomMessageDTO]) -> (nameIDs: Set, avatarIDs: Set) { + var nameIDs: Set = [] + var avatarIDs: Set = [] + for (index, message) in messages.enumerated() { + guard !message.isFromSelf else { continue } + let previous = index > 0 ? messages[index - 1] : nil + let next = index + 1 < messages.count ? messages[index + 1] : nil + let continuesFromPrevious = previous.map { incomingClusterContinues(from: $0, to: message) } ?? false + let continuesToNext = next.map { incomingClusterContinues(from: message, to: $0) } ?? false + if !continuesFromPrevious { nameIDs.insert(message.id) } + if !continuesToNext { avatarIDs.insert(message.id) } + } + return (nameIDs, avatarIDs) + } + + static func tiledRows(in messages: [RoomMessageDTO]) -> [RoomTiledRow] { + let bookends = incomingBookends(in: messages) + return messages.enumerated().map { index, message in + RoomTiledRow( + message: message, + showTimestamp: shouldShowTimestamp(at: index, in: messages), + showSenderName: bookends.nameIDs.contains(message.id), + showAvatar: bookends.avatarIDs.contains(message.id) + ) + } + } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift index 75e089ac3..3d8696845 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift @@ -5,10 +5,15 @@ import SwiftUI struct RoomMessageBubble: View { let message: RoomMessageDTO let showTimestamp: Bool + let showSenderName: Bool + let showAvatar: Bool var onRetry: (() -> Void)? var onLongPress: ((RoomMessageDTO) -> Void)? @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.openURL) private var openURL @State private var isLongPressing = false @State private var longPressTrigger = 0 @@ -32,6 +37,19 @@ struct RoomMessageBubble: View { makeBubbleContent() makeStatusIndicator() } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityMessageLabel) + .accessibilityAction { + onLongPress?(message) + } + .accessibilityActions { + if accessibilityShowsRetryAction { + Button(L10n.Chats.Chats.Message.Action.retry) { performAccessibilityRetry() } + } + ForEach(accessibilityLinkActions(formatted: formattedBodyText)) { action in + Button(action.name) { openURL(action.url) } + } + } if !isFromSelf { Spacer(minLength: 60) @@ -47,11 +65,49 @@ struct RoomMessageBubble: View { TimestampView(date: message.date) } + var accessibilityMessageLabel: String { + if isFromSelf { + return "\(message.text), \(message.accessibilityStatusLabel)" + } + return "\(message.authorDisplayName): \(message.text)" + } + + var accessibilityShowsRetryAction: Bool { + message.status == .failed && onRetry != nil + } + + func performAccessibilityRetry() { + onRetry?() + } + + func accessibilityLinkActions(formatted: AttributedString) -> [MessageLinkAccessibility.Action] { + MessageLinkAccessibility.actions(previewURL: nil, formatted: formatted) + } + + private var formattedBodyText: AttributedString { + MessageText.buildFormattedText( + text: message.text, + isOutgoing: isFromSelf, + currentUserName: nil, + isHighContrast: colorSchemeContrast == .increased, + outgoingTextColor: theme.outgoingTextColor, + hashtagColor: theme.hashtagColor, + identityGamut: theme.identityGamut, + identityBackgroundLuminances: theme.avatarSurfaceLuminances( + colorScheme: colorScheme, + contrast: colorSchemeContrast + ) + ).text + } + private func makeBubbleContent() -> some View { BubbleContent( message: message, isFromSelf: isFromSelf, - highContrast: colorSchemeContrast == .increased + showSenderName: showSenderName, + showAvatar: showAvatar, + highContrast: colorSchemeContrast == .increased, + formattedBodyText: formattedBodyText ) .messageBubbleLongPressGesture( isPressing: $isLongPressing, @@ -90,7 +146,10 @@ private struct TimestampView: View { private struct BubbleContent: View { let message: RoomMessageDTO let isFromSelf: Bool + let showSenderName: Bool + let showAvatar: Bool let highContrast: Bool + let formattedBodyText: AttributedString @Environment(\.appTheme) private var theme @Environment(\.colorScheme) private var colorScheme @@ -111,7 +170,7 @@ private struct BubbleContent: View { var body: some View { VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 0) { - if !isFromSelf { + if !isFromSelf, showSenderName { Text(message.authorDisplayName) .font(.footnote) .bold() @@ -121,15 +180,26 @@ private struct BubbleContent: View { contrast: highContrast ? .increased : .standard )) .senderNamePlacement() + .padding(.leading, IncomingBubbleAvatarMetrics.columnWidth) + .accessibilityHidden(true) } - MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(bubbleBackground) - .clipShape(.rect(cornerRadius: 16, style: .continuous)) + IncomingAvatarGutter( + identity: showAvatar ? .initials(name: message.authorDisplayName) : nil, + reserveColumn: !isFromSelf + ) { + messageBox + } } } + + private var messageBox: some View { + MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf, precomputedText: formattedBodyText) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(bubbleBackground) + .clipShape(.rect(cornerRadius: 16, style: .continuous)) + } } // MARK: - Status Indicator @@ -187,7 +257,9 @@ private struct StatusIndicator: View { timestamp: UInt32(Date().timeIntervalSince1970), isFromSelf: true ), - showTimestamp: true + showTimestamp: true, + showSenderName: false, + showAvatar: false ) } @@ -201,7 +273,9 @@ private struct StatusIndicator: View { timestamp: UInt32(Date().timeIntervalSince1970), isFromSelf: false ), - showTimestamp: true + showTimestamp: true, + showSenderName: true, + showAvatar: true ) } @@ -216,7 +290,9 @@ private struct StatusIndicator: View { isFromSelf: true, status: .pending ), - showTimestamp: true + showTimestamp: true, + showSenderName: false, + showAvatar: false ) } @@ -232,6 +308,8 @@ private struct StatusIndicator: View { status: .failed ), showTimestamp: true, + showSenderName: false, + showAvatar: false, onRetry: { print("Retry tapped") } ) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift b/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift new file mode 100644 index 000000000..3388af6b0 --- /dev/null +++ b/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift @@ -0,0 +1,15 @@ +import Foundation +import MC1Services + +/// Tiled room item. Chrome flags live here so `ChatTiledView` reconfigures +/// when a cluster follow-up hides the previous avatar (DTO equality would not). +struct RoomTiledRow: Identifiable, Hashable, Sendable { + let message: RoomMessageDTO + let showTimestamp: Bool + let showSenderName: Bool + let showAvatar: Bool + + var id: UUID { + message.id + } +} diff --git a/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift b/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift index 00671ccb3..09889d899 100644 --- a/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift +++ b/MC1Services/Sources/MC1Services/Models/MessageEnvelope.swift @@ -15,6 +15,8 @@ public struct MessageEnvelope: Sendable, Hashable { public let hasFailed: Bool public let containsSelfMention: Bool public let mentionSeen: Bool + /// Present only on channel incoming cluster-end rows. Never a JPEG. + public let incomingAvatar: IncomingAvatarIdentity? public init( messageID: UUID, @@ -25,7 +27,8 @@ public struct MessageEnvelope: Sendable, Hashable { date: Date, hasFailed: Bool, containsSelfMention: Bool, - mentionSeen: Bool + mentionSeen: Bool, + incomingAvatar: IncomingAvatarIdentity? ) { self.messageID = messageID self.isOutgoing = isOutgoing @@ -36,6 +39,7 @@ public struct MessageEnvelope: Sendable, Hashable { self.hasFailed = hasFailed self.containsSelfMention = containsSelfMention self.mentionSeen = mentionSeen + self.incomingAvatar = incomingAvatar } /// Returns a new envelope with `status` (and the derived `hasFailed`) @@ -50,7 +54,23 @@ public struct MessageEnvelope: Sendable, Hashable { date: date, hasFailed: status == .failed, containsSelfMention: containsSelfMention, - mentionSeen: mentionSeen + mentionSeen: mentionSeen, + incomingAvatar: incomingAvatar + ) + } + + public func with(incomingAvatar: IncomingAvatarIdentity?) -> MessageEnvelope { + MessageEnvelope( + messageID: messageID, + isOutgoing: isOutgoing, + senderName: senderName, + senderResolution: senderResolution, + status: status, + date: date, + hasFailed: hasFailed, + containsSelfMention: containsSelfMention, + mentionSeen: mentionSeen, + incomingAvatar: incomingAvatar ) } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/IncomingAvatarIdentity.swift b/MC1Services/Sources/MC1Services/Models/Rendering/IncomingAvatarIdentity.swift new file mode 100644 index 000000000..48132548a --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/Rendering/IncomingAvatarIdentity.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct IncomingAvatarIdentity: Sendable, Hashable { + public let name: String + /// Unique-name channel match. Local `Contact.id`, never a DM conversation key. + public let matchedContactID: UUID? + public let imageRevision: UInt64? + + public init(name: String, matchedContactID: UUID?, imageRevision: UInt64?) { + self.name = name + self.matchedContactID = matchedContactID + self.imageRevision = imageRevision + } + + public static func initials(name: String) -> IncomingAvatarIdentity { + IncomingAvatarIdentity(name: name, matchedContactID: nil, imageRevision: nil) + } + + /// Unique-name photo only when `senderNodeName` is non-empty after trim. + /// Prefix-resolved `displayName` is initials chrome, never a table key. + public static func resolve( + senderNodeName: String?, + displayName: String, + table: [String: IncomingAvatarIdentity] + ) -> IncomingAvatarIdentity { + if let raw = senderNodeName?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + let identity = table[raw.lowercased()] { + return identity + } + return .initials(name: displayName) + } + + /// Process-lifetime token. `Hasher` mixes the JPEG bytes; do not persist. + public static func revision(of data: Data?) -> UInt64? { + guard let data, !data.isEmpty else { return nil } + var hasher = Hasher() + hasher.combine(data) + return UInt64(bitPattern: Int64(hasher.finalize())) + } +} diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift index 47bae5243..3baac58c5 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift @@ -49,6 +49,8 @@ public struct MessageBuildInputs: Sendable, Hashable { public let showNewMessagesDivider: Bool /// True for the first message of a new calendar day; drives the day separator. public let showDayDivider: Bool + /// Present only on channel incoming cluster-end rows. Never a JPEG. + public let incomingAvatar: IncomingAvatarIdentity? public init( messageID: UUID, @@ -73,7 +75,8 @@ public struct MessageBuildInputs: Sendable, Hashable { showDirectionGap: Bool, showSenderName: Bool, showNewMessagesDivider: Bool, - showDayDivider: Bool = false + showDayDivider: Bool = false, + incomingAvatar: IncomingAvatarIdentity? = nil ) { self.messageID = messageID self.previewState = previewState @@ -98,5 +101,6 @@ public struct MessageBuildInputs: Sendable, Hashable { self.showSenderName = showSenderName self.showNewMessagesDivider = showNewMessagesDivider self.showDayDivider = showDayDivider + self.incomingAvatar = incomingAvatar } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift index da261f3c8..cca183d44 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift @@ -50,18 +50,19 @@ public struct MessageItem: Identifiable, Sendable, Hashable { 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. + /// Returns a new item with the supplied envelope, footer, and/or grouping + /// overridden. Eliminates the 6-field rebuild at single-row mutation sites. public func with( envelope: MessageEnvelope? = nil, - footer: MessageFooter? = nil + footer: MessageFooter? = nil, + grouping: GroupingFlags? = nil ) -> MessageItem { MessageItem( id: id, envelope: envelope ?? self.envelope, content: content, footer: footer ?? self.footer, - grouping: grouping, + grouping: grouping ?? self.grouping, shouldRequestPreviewFetch: shouldRequestPreviewFetch ) } diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift index 64daeaad9..ab22934f2 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator+Mutations.swift @@ -117,8 +117,7 @@ extension ChatCoordinator { /// 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 + updateRenderState { $0.appendingItem(item) } } /// Replace a single render-state item by ID via `transform`. No-op on diff --git a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift index 267ef3857..158487069 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift @@ -198,7 +198,8 @@ public enum MessageFragmentBuilder { date: message.senderDate, hasFailed: message.hasFailed, containsSelfMention: message.containsSelfMention, - mentionSeen: message.mentionSeen + mentionSeen: message.mentionSeen, + incomingAvatar: inputs.incomingAvatar ) } diff --git a/MC1Tests/State/ChatTimelineFreshnessTests.swift b/MC1Tests/State/ChatTimelineFreshnessTests.swift index 33f809bf0..d01ee8782 100644 --- a/MC1Tests/State/ChatTimelineFreshnessTests.swift +++ b/MC1Tests/State/ChatTimelineFreshnessTests.swift @@ -15,7 +15,7 @@ import Testing /// `AppState.ensureChatPrewarmRefresher()` wiring end to end; the unit tests /// in ChatPrewarmRefresherTests inject test hooks and cannot catch a /// production-wiring failure. -@Suite("ChatTimelineFreshness", .serialized) +@Suite("ChatTimelineFreshness", .serialized, .isolatedIncomingAvatarJPEGStore) @MainActor struct ChatTimelineFreshnessTests { // MARK: - Fixtures @@ -91,7 +91,13 @@ struct ChatTimelineFreshnessTests { ) } - private func makeChannelMessage(radioID: UUID, channelIndex: UInt8 = 0, timestamp: UInt32, text: String) -> MessageDTO { + private func makeChannelMessage( + radioID: UUID, + channelIndex: UInt8 = 0, + timestamp: UInt32, + text: String, + senderNodeName: String = "Sender" + ) -> MessageDTO { MessageDTO( id: UUID(), radioID: radioID, @@ -107,7 +113,7 @@ struct ChatTimelineFreshnessTests { pathLength: 0, snr: nil, senderKeyPrefix: nil, - senderNodeName: "Sender", + senderNodeName: senderNodeName, isRead: false, replyToID: nil, roundTripTime: nil, @@ -149,7 +155,7 @@ struct ChatTimelineFreshnessTests { dataStore: dataStore, bake: bake, envInputs: .default, - senderTables: .empty, + senderTables: { .empty }, postApply: nil, anchorSortDate: nil ) @@ -163,6 +169,73 @@ struct ChatTimelineFreshnessTests { #expect(dividerItemID == newMessage.id) } + @Test + func `populate bakeAll reads sender tables after the fetch await`() async throws { + let dataStore = try makeStore() + let registry = ChatCoordinatorRegistry(dataStore: dataStore) + let radioID = UUID() + let channel = makeChannel(radioID: radioID) + try await dataStore.saveChannel(channel) + + let alice = makeChannelMessage( + radioID: radioID, + timestamp: 1000, + text: "hi", + senderNodeName: "Alice" + ) + try await dataStore.saveMessage(alice) + + let contactID = UUID() + let stale = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: contactID, + imageRevision: 1 + ) + let live = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: contactID, + imageRevision: 2 + ) + var tables = ChatSenderTables( + contacts: [], + nicknamesByLoweredName: [:], + incomingAvatars: ["alice": stale] + ) + + let coordinator = registry.coordinator(for: .channel(radioID: radioID, channelIndex: channel.index)) + let owner = WriterOwner() + let writer = try #require(coordinator.bindWriter(owner: owner, role: .prime)) + let bake = ChatMessageBakeState() + + coordinator.testPopulateAfterFetchHook = { + tables = ChatSenderTables( + contacts: [], + nicknamesByLoweredName: [:], + incomingAvatars: ["alice": live] + ) + } + defer { coordinator.testPopulateAfterFetchHook = nil } + + let outcome = await ChatTimelinePopulator.populate( + .channel(channel), + writer: writer, + dataStore: dataStore, + bake: bake, + envInputs: .default, + senderTables: { tables }, + postApply: nil, + anchorSortDate: nil + ) + guard case .loaded = outcome else { + Issue.record("populate outcome was \(outcome), expected .loaded") + return + } + + await coordinator.buildItemsTask?.value + let item = coordinator.renderState.items.first { $0.id == alice.id } + #expect(item?.envelope.incomingAvatar == live) + } + // MARK: - Production hook chain, arrival-time refresh @Test diff --git a/MC1Tests/State/ChatTimelinePrimerTests.swift b/MC1Tests/State/ChatTimelinePrimerTests.swift index 3aa314882..993f992ce 100644 --- a/MC1Tests/State/ChatTimelinePrimerTests.swift +++ b/MC1Tests/State/ChatTimelinePrimerTests.swift @@ -8,7 +8,7 @@ import Testing /// `ChatCoordinator.bindWriter` seam: stale post-resume writes drop, binds deny /// under an open interactive owner, and channel primes resolve senders through /// contacts. -@Suite("ChatTimelinePrimer", .serialized) +@Suite("ChatTimelinePrimer", .serialized, .isolatedIncomingAvatarJPEGStore) @MainActor struct ChatTimelinePrimerTests { // MARK: - Fixtures @@ -38,7 +38,8 @@ struct ChatTimelinePrimerTests { id: UUID = UUID(), name: String = "TestContact", nickname: String? = nil, - unreadCount: Int = 0 + unreadCount: Int = 0, + avatarImageData: Data? = nil ) -> ContactDTO { ContactDTO( id: id, @@ -59,7 +60,8 @@ struct ChatTimelinePrimerTests { isMuted: false, isFavorite: false, lastMessageDate: nil, - unreadCount: unreadCount + unreadCount: unreadCount, + avatarImageData: avatarImageData ) } @@ -228,7 +230,12 @@ struct ChatTimelinePrimerTests { let channel = makeChannel(radioID: radioID) let wireName = "AlphaNode" let nickname = "Alpha" - let contact = makeContact(radioID: radioID, name: wireName, nickname: nickname) + let contact = makeContact( + radioID: radioID, + name: wireName, + nickname: nickname, + avatarImageData: Data("prime-jpeg".utf8) + ) try await dataStore.saveChannel(channel) try await dataStore.saveContact(contact) @@ -256,6 +263,8 @@ struct ChatTimelinePrimerTests { #expect(item.envelope.senderResolution.matchKind != .unresolved) #expect(item.envelope.senderResolution.displayName == wireName) #expect(item.envelope.senderResolution.unverifiedNickname == nickname) + #expect(item.envelope.incomingAvatar?.matchedContactID == contact.id) + #expect(IncomingAvatarJPEGStore.data(for: contact.id) != nil) } @Test diff --git a/MC1Tests/ViewModels/ChatViewModelPreviewSeedTests.swift b/MC1Tests/ViewModels/ChatViewModelPreviewSeedTests.swift index 5715ca4db..e8ab4ef59 100644 --- a/MC1Tests/ViewModels/ChatViewModelPreviewSeedTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPreviewSeedTests.swift @@ -44,7 +44,7 @@ struct ChatViewModelPreviewSeedTests { viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) let message = makeMessage(text: "look at https://example.com/article") - let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + let inputs = viewModel.makeBuildInputs(for: message, previous: nil, next: nil) #expect(inputs.cachedURL == URL(string: "https://example.com/article")) #expect(viewModel.bake.cachedURLs[message.id] != nil, @@ -69,7 +69,7 @@ struct ChatViewModelPreviewSeedTests { for: url ) - let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + let inputs = viewModel.makeBuildInputs(for: message, previous: nil, next: nil) #expect(inputs.previewState == .loaded, "a decoded-cache hit must paint .loaded in the same build, skipping the shimmer") @@ -114,7 +114,7 @@ struct ChatViewModelPreviewSeedTests { ) let message = makeMessage(text: "see \(urlString)") - let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + let inputs = viewModel.makeBuildInputs(for: message, previous: nil, next: nil) let aspect = try #require(inputs.previewHeroAspect) #expect(abs(aspect - 1200.0 / 630.0) < 0.001, @@ -127,7 +127,7 @@ struct ChatViewModelPreviewSeedTests { viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) let message = makeMessage(text: "no links here") - let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + let inputs = viewModel.makeBuildInputs(for: message, previous: nil, next: nil) #expect(inputs.cachedURL == nil) // The dictionary is `[UUID: URL?]`, so a stored negative result is the double diff --git a/MC1Tests/ViewModels/ChatViewModelTests.swift b/MC1Tests/ViewModels/ChatViewModelTests.swift index e5712d2fa..0cf59146f 100644 --- a/MC1Tests/ViewModels/ChatViewModelTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelTests.swift @@ -116,7 +116,7 @@ struct ChatViewModelTests { createTestMessage(timestamp: 1000) ] - let flags = ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil) + let flags = ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil) #expect(flags.showTimestamp == true) } @@ -132,13 +132,13 @@ struct ChatViewModelTests { ] // First message always shows timestamp - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) // Messages 1-4 shouldn't show timestamp (within 5 min of previous) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showTimestamp == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[3], previous: messages[2]).showTimestamp == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[4], previous: messages[3]).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[3], previous: messages[2], next: nil).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[4], previous: messages[3], next: nil).showTimestamp == false) } @Test @@ -149,8 +149,8 @@ struct ChatViewModelTests { createTestMessage(timestamp: baseTime + 301) // 5 min 1 sec later ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showTimestamp == true) } @Test @@ -161,8 +161,8 @@ struct ChatViewModelTests { createTestMessage(timestamp: baseTime + 300) // Exactly 5 minutes ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == false) // 300 is not > 300 + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showTimestamp == false) // 300 is not > 300 } @Test @@ -173,7 +173,7 @@ struct ChatViewModelTests { 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(ChatMessageBakeState.computeDisplayFlags(for: later, previous: earlier).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: later, previous: earlier, next: nil).showTimestamp == true) } @Test @@ -290,12 +290,12 @@ struct ChatViewModelTests { createTestMessage(timestamp: baseTime + 920) // 5: 20 sec - no show ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showTimestamp == true) // 360s gap - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[3], previous: messages[2]).showTimestamp == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[4], previous: messages[3]).showTimestamp == true) // 420s gap - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[5], previous: messages[4]).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showTimestamp == true) // 360s gap + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[3], previous: messages[2], next: nil).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[4], previous: messages[3], next: nil).showTimestamp == true) // 420s gap + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[5], previous: messages[4], next: nil).showTimestamp == false) } @Test @@ -392,7 +392,7 @@ struct ChatViewModelTests { let first = createTestMessage(timestamp: baseTime, text: "Hello") let second = createTestMessage(timestamp: baseTime, text: "World") - let flags = ChatMessageBakeState.computeDisplayFlags(for: second, previous: first) + let flags = ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) #expect(flags.showTimestamp == false) #expect(flags.showDirectionGap == false) } @@ -403,7 +403,7 @@ struct ChatViewModelTests { createTestMessage(timestamp: 1000) ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) } @Test @@ -412,7 +412,7 @@ struct ChatViewModelTests { // sessions, so distinct anchors). Grouping follows send time, so no header appears. let msg1 = createTestMessage(timestamp: 1000, sortDate: Date(timeIntervalSince1970: 1_000_000)) let msg2 = createTestMessage(timestamp: 1001, sortDate: Date(timeIntervalSince1970: 1_000_600)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: msg2, previous: msg1).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: msg2, previous: msg1, next: nil).showTimestamp == false) } @Test @@ -421,7 +421,7 @@ struct ChatViewModelTests { // follow send time, not drain time, so the rows stay grouped with no header. let msg1 = createTestMessage(timestamp: 1000, createdAt: Date(timeIntervalSince1970: 2_000_000)) let msg2 = createTestMessage(timestamp: 1001, createdAt: Date(timeIntervalSince1970: 2_000_600)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: msg2, previous: msg1).showTimestamp == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: msg2, previous: msg1, next: nil).showTimestamp == false) } @Test @@ -432,8 +432,8 @@ struct ChatViewModelTests { createTestMessage(timestamp: baseTime + 86400) // 24 hours later ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showTimestamp == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showTimestamp == true) } // MARK: - Conversation Filtering Tests @@ -610,7 +610,7 @@ struct ChatViewModelTests { let imageURL = try #require(URL(string: "https://example.com/photo.png")) viewModel.bake.cachedURLs[message.id] = imageURL - let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + let inputs = viewModel.makeBuildInputs(for: message, previous: nil, next: nil) #expect(inputs.isInlineImageURL == true) } @@ -626,7 +626,7 @@ struct ChatViewModelTests { viewModel.bake.cachedURLs[message.id] = pageURL viewModel.bake.imageURLsServingPages.insert(pageURL.absoluteString) - let inputs = viewModel.makeBuildInputs(for: message, previous: nil) + let inputs = viewModel.makeBuildInputs(for: message, previous: nil, next: nil) #expect(inputs.isInlineImageURL == false) } @@ -729,7 +729,7 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1000, senderName: "Alice") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) } @Test @@ -740,9 +740,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1120, senderName: "Alice") // 2 min later ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showSenderName == false) } @Test @@ -752,8 +752,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1060, senderName: "Bob") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) } @Test @@ -763,8 +763,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1301, senderName: "Alice") // 5 min 1 sec later ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) } @Test @@ -774,8 +774,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1300, senderName: "Alice") // Exactly 5 min ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == false) } @Test @@ -786,9 +786,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1120, senderName: "Alice") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showSenderName == true) } @Test @@ -799,9 +799,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1120, senderName: nil, isOutgoing: true) ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == false) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showSenderName == false) } @Test @@ -812,9 +812,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1120, senderName: "Alice") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil).showSenderName == true) } @Test @@ -824,8 +824,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1060, senderName: nil) // malformed message ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) } @Test @@ -835,8 +835,8 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1060, senderName: "") ] - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil).showSenderName == true) - #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil).showSenderName == true) } @Test @@ -853,7 +853,7 @@ struct DisplayFlagsTests { ) let dto = MessageDTO(from: message) - #expect(ChatMessageBakeState.computeDisplayFlags(for: dto, previous: nil).showSenderName == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: dto, previous: nil, next: nil).showSenderName == true) } @Test @@ -863,9 +863,9 @@ struct DisplayFlagsTests { createChannelMessage(timestamp: 1060, senderName: "Alice", isOutgoing: true), createChannelMessage(timestamp: 1120, senderName: "Alice") ] - let flags0 = ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil) - let flags1 = ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0]) - let flags2 = ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1]) + let flags0 = ChatMessageBakeState.computeDisplayFlags(for: messages[0], previous: nil, next: nil) + let flags1 = ChatMessageBakeState.computeDisplayFlags(for: messages[1], previous: messages[0], next: nil) + let flags2 = ChatMessageBakeState.computeDisplayFlags(for: messages[2], previous: messages[1], next: nil) #expect(flags0.showDirectionGap == false) #expect(flags1.showDirectionGap == true) #expect(flags2.showDirectionGap == true) @@ -876,21 +876,21 @@ struct DisplayFlagsTests { @Test func `First message always shows day divider`() { let message = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: message, previous: nil).showDayDivider == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: message, previous: nil, next: nil).showDayDivider == true) } @Test func `Same calendar day hides day divider`() { let m0 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10, 0)) let m1 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10, 1)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0).showDayDivider == false) + #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0, next: nil).showDayDivider == false) } @Test func `Calendar day change shows day divider`() { let m0 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10)) let m1 = createTestMessage(timestamp: makeTimestamp(2024, 5, 2, 10)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0).showDayDivider == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0, next: nil).showDayDivider == true) } @Test @@ -900,7 +900,7 @@ struct DisplayFlagsTests { let receiveDay = makeDate(2024, 6, 2, 14) let m0 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 10), createdAt: receiveDay) let m1 = createTestMessage(timestamp: makeTimestamp(2024, 5, 2, 10), createdAt: receiveDay) - #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0).showDayDivider == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0, next: nil).showDayDivider == true) } @Test @@ -909,7 +909,7 @@ struct DisplayFlagsTests { // messages straddle midnight, so the day divider must still show. let m0 = createTestMessage(timestamp: makeTimestamp(2024, 5, 1, 23, 58)) let m1 = createTestMessage(timestamp: makeTimestamp(2024, 5, 2, 0, 1)) - #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0).showDayDivider == true) + #expect(ChatMessageBakeState.computeDisplayFlags(for: m1, previous: m0, next: nil).showDayDivider == true) } } diff --git a/MC1Tests/ViewModels/IncomingAvatarClusterTests.swift b/MC1Tests/ViewModels/IncomingAvatarClusterTests.swift new file mode 100644 index 000000000..c5be7820e --- /dev/null +++ b/MC1Tests/ViewModels/IncomingAvatarClusterTests.swift @@ -0,0 +1,401 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import SwiftData +import Testing + +@Suite("Incoming avatar cluster", .isolatedIncomingAvatarJPEGStore) +@MainActor +struct IncomingAvatarClusterTests { + init() { + MapSnapshotStore.shared.clear() + } + + @Test + func `two incoming channel same sender 60s — first hides avatar, second shows`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == false + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `sender change — both incoming channel rows show avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Bob") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == true + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `incoming then outgoing channel — incoming shows avatar`() { + let incoming = createChannelMessage(timestamp: 1000, senderName: "Alice") + let outgoing = createChannelMessage(timestamp: 1060, senderName: nil, isOutgoing: true) + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: incoming, previous: nil, next: outgoing) + .isClusterEnd == true + ) + } + + @Test + func `300s gap — first incoming channel hides avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1300, senderName: "Alice") + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == false + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `two nil-name incoming channel 60s — both show avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: nil) + let second = createChannelMessage(timestamp: 1060, senderName: nil) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == true + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `301s gap — first incoming channel shows avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1301, senderName: "Alice") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == true + ) + } + + @Test + func `day change within 300s same sender — cluster continues`() { + let first = createChannelMessage( + timestamp: makeTimestamp(2024, 5, 1, 23, 58), + senderName: "Alice" + ) + let second = createChannelMessage( + timestamp: makeTimestamp(2024, 5, 2, 0, 1), + senderName: "Alice" + ) + + let firstFlags = ChatMessageBakeState.computeDisplayFlags( + for: first, previous: nil, next: second + ) + let secondFlags = ChatMessageBakeState.computeDisplayFlags( + for: second, previous: first, next: nil + ) + #expect(firstFlags.isClusterEnd == false) + #expect(secondFlags.isClusterEnd == true) + #expect(secondFlags.showDayDivider == true) + } + + @Test + func `two incoming DMs 60s apart both hide avatar`() { + let first = createIncomingDM(timestamp: 1000) + let second = createIncomingDM(timestamp: 1060) + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == false + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == false + ) + } + + @Test + func `outgoing channel hides avatar`() { + let message = createChannelMessage(timestamp: 1000, senderName: nil, isOutgoing: true) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: message, previous: nil, next: nil) + .isClusterEnd == false + ) + } + + @Test + func `two empty-name incoming channel 60s — first hides avatar, second shows`() { + let first = createChannelMessage(timestamp: 1000, senderName: "") + let second = createChannelMessage(timestamp: 1060, senderName: "") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == false + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `Alice then empty name — both incoming channel rows show avatar`() { + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "") + + #expect( + ChatMessageBakeState.computeDisplayFlags(for: first, previous: nil, next: second) + .isClusterEnd == true + ) + #expect( + ChatMessageBakeState.computeDisplayFlags(for: second, previous: first, next: nil) + .isClusterEnd == true + ) + } + + @Test + func `channel follow-up admit hands avatar to the new cluster-end`() { + let viewModel = boundViewModel() + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + viewModel.appendMessageIfNew(first) + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + + viewModel.appendMessageIfNew(second) + #expect(viewModel.items.count == 2) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `incoming then outgoing channel admit keeps incoming avatar`() { + let viewModel = boundViewModel() + let incoming = createChannelMessage(timestamp: 1000, senderName: "Alice") + let outgoing = createChannelMessage(timestamp: 1060, senderName: nil, isOutgoing: true) + + viewModel.appendMessageIfNew(incoming) + viewModel.appendMessageIfNew(outgoing) + + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + #expect(viewModel.items[1].envelope.incomingAvatar == nil) + } + + @Test + func `DM admits do not rewrite the previous item`() throws { + let viewModel = boundViewModel() + let first = createIncomingDM(timestamp: 1000) + let second = createIncomingDM(timestamp: 1060) + + viewModel.appendMessageIfNew(first) + let afterFirst = try #require(viewModel.items.first) + + viewModel.appendMessageIfNew(second) + #expect(viewModel.items.count == 2) + #expect(viewModel.items[0] == afterFirst) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + #expect(viewModel.items[1].envelope.incomingAvatar == nil) + } + + @Test + func `delete last incoming channel promotes the previous avatar`() { + let viewModel = boundViewModel() + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + + viewModel.timeline.removeMessage(second.id) + + #expect(viewModel.items.count == 1) + #expect(viewModel.items[0].id == first.id) + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + } + + @Test + func `delete middle of 250s Alice cluster splits into two cluster-ends`() { + let viewModel = boundViewModel() + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let middle = createChannelMessage(timestamp: 1250, senderName: "Alice") + let last = createChannelMessage(timestamp: 1500, senderName: "Alice") + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(middle) + viewModel.appendMessageIfNew(last) + + viewModel.timeline.removeMessage(middle.id) + + #expect(viewModel.items.count == 2) + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + #expect(viewModel.items[1].grouping.showSenderName == true) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `delete middle of a tight Alice burst keeps one cluster-end`() { + let viewModel = boundViewModel() + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let middle = createChannelMessage(timestamp: 1060, senderName: "Alice") + let last = createChannelMessage(timestamp: 1120, senderName: "Alice") + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(middle) + viewModel.appendMessageIfNew(last) + + viewModel.timeline.removeMessage(middle.id) + + #expect(viewModel.items.count == 2) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `delete incoming DM leaves remaining item bit-identical`() { + let viewModel = boundViewModel() + let first = createIncomingDM(timestamp: 1000) + let second = createIncomingDM(timestamp: 1060) + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + let remaining = viewModel.items[0] + + viewModel.timeline.removeMessage(second.id) + + #expect(viewModel.items.count == 1) + #expect(viewModel.items[0] == remaining) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + } + + @Test + func `contact-table patch rewrites only rows that already have an identity`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channel = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 0, + name: "Ops", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + let alice = ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data((0.. ChatViewModel { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + return viewModel +} + +private func createChannelMessage( + timestamp: UInt32, + senderName: String? = nil, + isOutgoing: Bool = false +) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: nil, + channelIndex: 0, + text: "Test message", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: isOutgoing ? .outgoing : .incoming, + status: isOutgoing ? .sent : .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 createIncomingDM(timestamp: UInt32) -> MessageDTO { + let message = Message( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + text: "Test", + timestamp: timestamp, + directionRawValue: MessageDirection.incoming.rawValue, + statusRawValue: MessageStatus.delivered.rawValue + ) + return MessageDTO(from: message) +} + +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) + )! +} + +private func makeTimestamp(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int = 0) -> UInt32 { + UInt32(makeDate(year, month, day, hour, minute).timeIntervalSince1970) +} diff --git a/MC1Tests/ViewModels/IncomingAvatarIdentityTests.swift b/MC1Tests/ViewModels/IncomingAvatarIdentityTests.swift new file mode 100644 index 000000000..2826ee081 --- /dev/null +++ b/MC1Tests/ViewModels/IncomingAvatarIdentityTests.swift @@ -0,0 +1,72 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("IncomingAvatarIdentity") +struct IncomingAvatarIdentityTests { + @Test + func `revision of nil or empty is nil`() { + #expect(IncomingAvatarIdentity.revision(of: nil) == nil) + #expect(IncomingAvatarIdentity.revision(of: Data()) == nil) + } + + @Test + func `resolve trims senderNodeName before table lookup`() { + let photo = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + let resolved = IncomingAvatarIdentity.resolve( + senderNodeName: " Alice ", + displayName: " Alice ", + table: ["alice": photo] + ) + #expect(resolved == photo) + } + + @Test + func `resolve does not use displayName as a table key`() { + let photo = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + let resolved = IncomingAvatarIdentity.resolve( + senderNodeName: nil, + displayName: "Alice", + table: ["alice": photo] + ) + #expect(resolved == IncomingAvatarIdentity.initials(name: "Alice")) + } + + @Test + func `resolve does not steal another contact photo`() { + let alice = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + let resolved = IncomingAvatarIdentity.resolve( + senderNodeName: "Bob", + displayName: "Bob", + table: ["alice": alice] + ) + #expect(resolved == IncomingAvatarIdentity.initials(name: "Bob")) + } + + @Test + func `resolve empty senderNodeName is initials`() { + let photo = IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + let resolved = IncomingAvatarIdentity.resolve( + senderNodeName: "", + displayName: "Alice", + table: ["alice": photo] + ) + #expect(resolved == IncomingAvatarIdentity.initials(name: "Alice")) + } +} diff --git a/MC1Tests/ViewModels/RoomConversationViewModelOrderingTests.swift b/MC1Tests/ViewModels/RoomConversationViewModelOrderingTests.swift index 6d2cb0b26..197299050 100644 --- a/MC1Tests/ViewModels/RoomConversationViewModelOrderingTests.swift +++ b/MC1Tests/ViewModels/RoomConversationViewModelOrderingTests.swift @@ -82,4 +82,129 @@ struct RoomConversationViewModelOrderingTests { #expect(viewModel.messages.map(\.timestamp) == [100, 200]) } + + @Test + func `same-prefix burst books name on first and avatar on last`() { + let alice = Data([0xAA]) + let messages = [ + roomMessage(ts: 100, prefix: alice, name: "Alice"), + roomMessage(ts: 160, prefix: alice, name: "Alice"), + roomMessage(ts: 220, prefix: alice, name: "Alice") + ] + + let bookends = RoomConversationViewModel.incomingBookends(in: messages) + + #expect(bookends.nameIDs == [messages[0].id]) + #expect(bookends.avatarIDs == [messages[2].id]) + } + + @Test + func `different prefixes are two clusters even when display names match`() { + let messages = [ + roomMessage(ts: 100, prefix: Data([0xAA]), name: "Alice"), + roomMessage(ts: 160, prefix: Data([0xBB]), name: "Alice") + ] + + let bookends = RoomConversationViewModel.incomingBookends(in: messages) + + #expect(bookends.nameIDs == [messages[0].id, messages[1].id]) + #expect(bookends.avatarIDs == [messages[0].id, messages[1].id]) + } + + @Test + func `self messages never show a name or avatar`() { + let messages = [ + roomMessage(ts: 100, prefix: Data([0xAA]), name: "Me", isFromSelf: true), + roomMessage(ts: 160, prefix: Data([0xAA]), name: "Me", isFromSelf: true) + ] + + let bookends = RoomConversationViewModel.incomingBookends(in: messages) + + #expect(bookends.nameIDs.isEmpty) + #expect(bookends.avatarIDs.isEmpty) + } + + @Test + func `follow-up flips previous row Hashable identity so the tiled cell reconfigures`() { + let prefix = Data([0xAA]) + let first = roomMessage(ts: 100, prefix: prefix, name: "Alice") + let second = roomMessage(ts: 160, prefix: prefix, name: "Alice") + + let before = RoomConversationViewModel.tiledRows(in: [first]) + let after = RoomConversationViewModel.tiledRows(in: [first, second]) + + #expect(before[0].id == first.id) + #expect(before[0].showAvatar == true) + #expect(after[0].id == first.id) + #expect(after[0].showAvatar == false) + #expect(after[1].showAvatar == true) + #expect(before[0] != after[0]) + } + + @Test + func `mid-insert flips the next row timestamp flag`() { + let prefix = Data([0xAA]) + let first = roomMessage(ts: 100, prefix: prefix, name: "Alice") + let later = roomMessage(ts: 500, prefix: prefix, name: "Alice") + let mid = roomMessage(ts: 250, prefix: prefix, name: "Alice") + + let before = RoomConversationViewModel.tiledRows(in: [first, later]) + #expect(before[1].showTimestamp == true) + + let after = RoomConversationViewModel.tiledRows(in: [first, mid, later]) + #expect(after[2].id == later.id) + #expect(after[2].showTimestamp == false) + #expect(before[1] != after[2]) + } + + @Test + func `300s same-prefix is one cluster; 301s is two`() { + let prefix = Data([0xAA]) + let a = roomMessage(ts: 1000, prefix: prefix, name: "Alice") + let at300 = roomMessage(ts: 1300, prefix: prefix, name: "Alice") + let at301 = roomMessage(ts: 1301, prefix: prefix, name: "Alice") + + let clustered = RoomConversationViewModel.tiledRows(in: [a, at300]) + #expect(clustered[0].showSenderName == true) + #expect(clustered[0].showAvatar == false) + #expect(clustered[1].showSenderName == false) + #expect(clustered[1].showAvatar == true) + + let split = RoomConversationViewModel.tiledRows(in: [a, at301]) + #expect(split[0].showAvatar == true) + #expect(split[1].showSenderName == true) + #expect(split[1].showAvatar == true) + } + + @Test + func `self message breaks an incoming prefix cluster`() { + let prefix = Data([0xAA]) + let first = roomMessage(ts: 100, prefix: prefix, name: "Alice") + let me = roomMessage(ts: 160, prefix: prefix, name: "Alice", isFromSelf: true) + let third = roomMessage(ts: 220, prefix: prefix, name: "Alice") + + let rows = RoomConversationViewModel.tiledRows(in: [first, me, third]) + #expect(rows[0].showSenderName == true) + #expect(rows[0].showAvatar == true) + #expect(rows[1].showSenderName == false) + #expect(rows[1].showAvatar == false) + #expect(rows[2].showSenderName == true) + #expect(rows[2].showAvatar == true) + } + + private func roomMessage( + ts: UInt32, + prefix: Data, + name: String, + isFromSelf: Bool = false + ) -> RoomMessageDTO { + RoomMessageDTO( + sessionID: sessionID, + authorKeyPrefix: prefix, + authorName: name, + text: "msg", + timestamp: ts, + isFromSelf: isFromSelf + ) + } } diff --git a/MC1Tests/Views/Chats/Components/IncomingAvatarJPEGStoreTests.swift b/MC1Tests/Views/Chats/Components/IncomingAvatarJPEGStoreTests.swift new file mode 100644 index 000000000..9e2bbdd22 --- /dev/null +++ b/MC1Tests/Views/Chats/Components/IncomingAvatarJPEGStoreTests.swift @@ -0,0 +1,67 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("IncomingAvatarJPEGStore", .isolatedIncomingAvatarJPEGStore) +@MainActor +struct IncomingAvatarJPEGStoreTests { + @Test + func `replace stores JPEG only when matchedContactID owns the bytes`() { + let contact = makeContact(name: "Maya", jpeg: Data("unique-jpeg".utf8)) + replace(contact) + + #expect(IncomingAvatarJPEGStore.data(for: contact.id) == contact.avatarImageData) + #expect(IncomingAvatarJPEGStore.data(for: UUID()) == nil) + } + + @Test + func `nested isolated storage does not clobber the outer session`() { + let outer = makeContact(name: "Maya", jpeg: Data("outer-jpeg".utf8)) + let inner = makeContact(name: "Rico", jpeg: Data("inner-jpeg".utf8)) + + IncomingAvatarJPEGStore.$isolatedStorage.withValue(IncomingAvatarJPEGStore.Storage()) { + replace(outer) + #expect(IncomingAvatarJPEGStore.data(for: outer.id) == outer.avatarImageData) + + IncomingAvatarJPEGStore.$isolatedStorage.withValue(IncomingAvatarJPEGStore.Storage()) { + replace(inner) + #expect(IncomingAvatarJPEGStore.data(for: inner.id) == inner.avatarImageData) + #expect(IncomingAvatarJPEGStore.data(for: outer.id) == nil) + } + + #expect(IncomingAvatarJPEGStore.data(for: outer.id) == outer.avatarImageData) + #expect(IncomingAvatarJPEGStore.data(for: inner.id) == nil) + } + } + + private func replace(_ contact: ContactDTO) { + let identities = MessageBubbleConfiguration.incomingAvatarIdentities(from: [contact]) + IncomingAvatarJPEGStore.replace(contacts: [contact], identities: Array(identities.values)) + } + + private func makeContact(name: String, jpeg: Data) -> ContactDTO { + ContactDTO( + id: UUID(), + radioID: UUID(), + publicKey: Data(repeating: 0xAA, count: ProtocolLimits.publicKeySize), + name: name, + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0, + avatarImageData: jpeg + ) + } +} diff --git a/MC1Tests/Views/Chats/Components/IsolatedIncomingAvatarJPEGStoreTrait.swift b/MC1Tests/Views/Chats/Components/IsolatedIncomingAvatarJPEGStoreTrait.swift new file mode 100644 index 000000000..a24b8f5d7 --- /dev/null +++ b/MC1Tests/Views/Chats/Components/IsolatedIncomingAvatarJPEGStoreTrait.swift @@ -0,0 +1,27 @@ +@testable import MC1 +import Testing + +/// Binds a fresh `IncomingAvatarJPEGStore` box for each test so replace-all +/// cannot wipe another suite's map across an `await`. +struct IsolatedIncomingAvatarJPEGStoreTrait: SuiteTrait, TestTrait, TestScoping { + var isRecursive: Bool { + true + } + + func provideScope( + for test: Test, + testCase: Test.Case?, + performing function: () async throws -> Void + ) async throws { + let storage = await MainActor.run { IncomingAvatarJPEGStore.Storage() } + try await IncomingAvatarJPEGStore.$isolatedStorage.withValue(storage) { + try await function() + } + } +} + +extension SuiteTrait where Self == IsolatedIncomingAvatarJPEGStoreTrait { + static var isolatedIncomingAvatarJPEGStore: Self { + Self() + } +} diff --git a/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift b/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift index de73f2854..df146ebbc 100644 --- a/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift +++ b/MC1Tests/Views/Chats/Components/MessageStatusTextTests.swift @@ -22,7 +22,8 @@ struct MessageStatusTextTests { date: Date(timeIntervalSince1970: 1_700_000_000), hasFailed: status == .failed, containsSelfMention: false, - mentionSeen: false + mentionSeen: false, + incomingAvatar: nil ), content: [], footer: MessageFooter( diff --git a/MC1Tests/Views/Chats/Components/RoomMessageBubbleA11yLabelTests.swift b/MC1Tests/Views/Chats/Components/RoomMessageBubbleA11yLabelTests.swift new file mode 100644 index 000000000..bfbd48f75 --- /dev/null +++ b/MC1Tests/Views/Chats/Components/RoomMessageBubbleA11yLabelTests.swift @@ -0,0 +1,147 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@MainActor +@Suite("RoomMessageBubble accessibility label") +struct RoomMessageBubbleA11yLabelTests { + @Test + func `incoming follow-up label still includes the author`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0xAA]), + authorName: "Alice", + text: "follow up", + timestamp: 100 + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: false, + showAvatar: true + ) + + #expect(bubble.accessibilityMessageLabel == "Alice: follow up") + } + + @Test + func `incoming cluster-start announces the author once`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0xAA]), + authorName: "Alice", + text: "hello", + timestamp: 100 + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: true, + showAvatar: true + ) + + #expect(bubble.accessibilityMessageLabel == "Alice: hello") + #expect(bubble.accessibilityMessageLabel.components(separatedBy: "Alice").count == 2) + } + + @Test + func `outgoing label includes status once`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0x42]), + authorName: "Me", + text: "hello", + timestamp: 100, + isFromSelf: true, + status: .sent + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: false, + showAvatar: false + ) + #expect(bubble.accessibilityMessageLabel == "hello, \(message.accessibilityStatusLabel)") + let statusCount = bubble.accessibilityMessageLabel.components(separatedBy: message.accessibilityStatusLabel).count + #expect(statusCount == 2) + } + + @Test + func `failed outgoing exposes a retry accessibility action`() { + var retried = false + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0x42]), + authorName: "Me", + text: "hello", + timestamp: 100, + isFromSelf: true, + status: .failed + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: false, + showAvatar: false, + onRetry: { retried = true } + ) + #expect(bubble.accessibilityShowsRetryAction) + bubble.performAccessibilityRetry() + #expect(retried) + } + + @Test + func `sent outgoing does not expose a retry accessibility action`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0x42]), + authorName: "Me", + text: "hello", + timestamp: 100, + isFromSelf: true, + status: .sent + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: false, + showAvatar: false, + onRetry: {} + ) + #expect(!bubble.accessibilityShowsRetryAction) + } + + @Test + func `https body text yields a named open-link action`() { + let message = RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0xAA]), + authorName: "Alice", + text: "see https://example.com", + timestamp: 100 + ) + let bubble = RoomMessageBubble( + message: message, + showTimestamp: false, + showSenderName: true, + showAvatar: true + ) + let formatted = MessageText.buildFormattedText( + text: message.text, + isOutgoing: false, + currentUserName: nil, + isHighContrast: false, + outgoingTextColor: .white, + hashtagColor: .blue, + identityGamut: IdentityGamut( + hueAnchors: [18, 25, 44, 77, 120, 180, 215, 255, 307, 343], + saturation: 0.45...0.70 + ), + identityBackgroundLuminances: [0.2, 0.8] + ).text + let actions = bubble.accessibilityLinkActions(formatted: formatted) + #expect(actions.contains { $0.url.host() == "example.com" }) + #expect(actions.contains { $0.name == L10n.Chats.Chats.Message.Action.openWebLink("example.com") }) + } +} diff --git a/MC1Tests/Views/Chats/Components/UnifiedMessageBubbleA11yLabelTests.swift b/MC1Tests/Views/Chats/Components/UnifiedMessageBubbleA11yLabelTests.swift index b01d4368e..e6bc0b5f9 100644 --- a/MC1Tests/Views/Chats/Components/UnifiedMessageBubbleA11yLabelTests.swift +++ b/MC1Tests/Views/Chats/Components/UnifiedMessageBubbleA11yLabelTests.swift @@ -14,7 +14,8 @@ struct UnifiedMessageBubbleA11yLabelTests { regionScope: "NORTHWEST" ) let configuration = MessageBubbleConfiguration( - showSenderName: true + showSenderName: true, + showsIncomingAvatars: false ) let bundle = MessageBubbleTestData.messageItem( message: message, @@ -61,7 +62,8 @@ struct UnifiedMessageBubbleA11yLabelTests { func `fallback sender label includes possible-match disclosure`() { let message = MessageBubbleTestData.incomingChannel(text: "hi", senderNodeName: nil) let configuration = MessageBubbleConfiguration( - showSenderName: true + showSenderName: true, + showsIncomingAvatars: false ) let bundle = MessageBubbleTestData.messageItem( message: message, diff --git a/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift b/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift index a706a21f8..18b9aa285 100644 --- a/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift +++ b/MC1Tests/Views/Chats/MessageBubbleConfigurationTests.swift @@ -5,7 +5,13 @@ import Testing @Suite("MessageBubbleConfiguration") struct MessageBubbleConfigurationTests { - private func createContact(prefix: [UInt8], name: String, lastAdvertTimestamp: UInt32, nickname: String? = nil) -> ContactDTO { + private func createContact( + prefix: [UInt8], + name: String, + lastAdvertTimestamp: UInt32, + nickname: String? = nil, + avatarImageData: Data? = nil + ) -> ContactDTO { ContactDTO( id: UUID(), radioID: UUID(), @@ -25,7 +31,8 @@ struct MessageBubbleConfigurationTests { isMuted: false, isFavorite: false, lastMessageDate: nil, - unreadCount: 0 + unreadCount: 0, + avatarImageData: avatarImageData ) } @@ -55,6 +62,12 @@ struct MessageBubbleConfigurationTests { ) } + @Test + func `directMessage factory disables incoming avatars`() { + #expect(MessageBubbleConfiguration.directMessage.showsIncomingAvatars == false) + #expect(MessageBubbleConfiguration.channel(isPublic: true).showsIncomingAvatars == true) + } + @Test func `channel sender resolver marks short prefix match as fallback`() { let older = createContact(prefix: [0xAA, 0x01], name: "Older", lastAdvertTimestamp: 100) @@ -139,4 +152,58 @@ struct MessageBubbleConfigurationTests { #expect(result.unverifiedNickname == nil) } + + @Test + func `incomingAvatarIdentities maps a unique name without a nickname`() { + let contact = createContact(prefix: [0xAA, 0x01], name: "Alpha", lastAdvertTimestamp: 100) + + let lookup = MessageBubbleConfiguration.incomingAvatarIdentities(from: [contact]) + let identity = lookup["alpha"] + + #expect(identity?.matchedContactID == contact.id) + #expect(identity?.name == "Alpha") + #expect(identity?.imageRevision == nil) + } + + @Test + func `local avatar replace changes imageRevision when lastModified is unchanged`() { + let firstBytes = Data(repeating: 0x01, count: 64) + let secondBytes = Data(repeating: 0x02, count: 64) + let contact = createContact( + prefix: [0xAA, 0x01], + name: "Alpha", + lastAdvertTimestamp: 100, + avatarImageData: firstBytes + ) + let replaced = contact.with(avatarImageData: secondBytes) + #expect(contact.lastModified == replaced.lastModified) + + let first = MessageBubbleConfiguration.incomingAvatarIdentities(from: [contact])["alpha"] + let second = MessageBubbleConfiguration.incomingAvatarIdentities(from: [replaced])["alpha"] + #expect(first?.imageRevision != second?.imageRevision) + } + + @Test + func `incomingAvatarIdentities keeps a unique name when another name collides`() { + let alice = createContact(prefix: [0xAA, 0x01], name: "Alice", lastAdvertTimestamp: 100) + let bob1 = createContact(prefix: [0xAA, 0x02], name: "Bob", lastAdvertTimestamp: 100) + let bob2 = createContact(prefix: [0xAA, 0x03], name: "bob", lastAdvertTimestamp: 200) + + let lookup = MessageBubbleConfiguration.incomingAvatarIdentities(from: [alice, bob1, bob2]) + #expect(lookup["alice"]?.matchedContactID == alice.id) + #expect(lookup["bob"] == nil) + } + + @Test + func `incomingAvatarIdentities keys on contact name not nickname`() { + let contact = createContact( + prefix: [0xAA, 0x01], + name: "Alpha", + lastAdvertTimestamp: 100, + nickname: "Rico" + ) + let lookup = MessageBubbleConfiguration.incomingAvatarIdentities(from: [contact]) + #expect(lookup["alpha"]?.matchedContactID == contact.id) + #expect(lookup["rico"] == nil) + } } diff --git a/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift b/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift index 06b14ec3a..96d417acc 100644 --- a/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift +++ b/MC1Tests/Views/Chats/Models/ChatRenderStateTests.swift @@ -230,7 +230,8 @@ private func makeFakeMessageItem( date: Date(timeIntervalSince1970: 1_700_000_000), hasFailed: false, containsSelfMention: false, - mentionSeen: false + mentionSeen: false, + incomingAvatar: nil ), content: [], footer: MessageFooter( From ccad1ea8b3362678f3e48d1b5f2bc05e65e4f88e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:53:02 -0700 Subject: [PATCH 22/47] feat(chats): fly cluster-end avatar to new tail - Start the flight in the same admit turn as the new row - Skip when scrolled up or Reduce Motion is on - Key room hops on author prefix --- .../ChatConversationMessagesContent.swift | 22 +- .../Components/IncomingAvatarFlight.swift | 167 ++++++ .../Components/IncomingAvatarGutter.swift | 66 +- .../IncomingBubbleAvatarMetrics.swift | 2 + .../Components/UnifiedMessageBubble.swift | 3 +- .../Chats/Timeline/ChatTimeline+Paging.swift | 35 +- .../ChatViewModel+DisplayItems.swift | 10 +- MC1/Views/Chats/ViewModel/ChatViewModel.swift | 4 + .../Rooms/RoomConversationView.swift | 19 + .../Rooms/RoomConversationViewModel.swift | 15 + .../RemoteNodes/Rooms/RoomMessageBubble.swift | 3 +- MC1Tests/Views/Chats/ChatTimelineTests.swift | 6 +- .../IncomingAvatarFlightTests.swift | 565 ++++++++++++++++++ 13 files changed, 892 insertions(+), 25 deletions(-) create mode 100644 MC1/Views/Chats/Components/IncomingAvatarFlight.swift create mode 100644 MC1Tests/Views/Chats/Components/IncomingAvatarFlightTests.swift diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index bbc826da9..4fad508c1 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -42,6 +42,9 @@ struct ChatConversationMessagesContent: View { @Environment(\.appTheme) private var theme @Environment(\.openURL) private var openURL + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @State private var incomingAvatarFlight = IncomingAvatarFlight() // MARK: - Body @@ -75,7 +78,10 @@ struct ChatConversationMessagesContent: View { private var messagesList: some View { ChatTiledView( items: viewModel.items, - cellContent: cellFactory.makeContent(for:), + cellContent: { item in + cellFactory.makeContent(for: item) + .environment(\.incomingAvatarFlight, incomingAvatarFlight) + }, contentBackground: theme.surfaces?.canvas, isAtBottom: $isAtBottom, unreadCount: $unreadCount, @@ -87,6 +93,20 @@ struct ChatConversationMessagesContent: View { onLoadOlder: { await viewModel.loadOlderMessages() }, onInitialTargetConsumed: onDividerTargetConsumed ) + .overlay { + incomingAvatarFlight.overlay() + } + .onAppear { + viewModel.incomingAvatarFlight = incomingAvatarFlight + incomingAvatarFlight.isAtBottom = isAtBottom + incomingAvatarFlight.reduceMotion = reduceMotion + } + .onChange(of: isAtBottom, initial: true) { _, atBottom in + incomingAvatarFlight.isAtBottom = atBottom + } + .onChange(of: reduceMotion, initial: true) { _, reduce in + incomingAvatarFlight.reduceMotion = reduce + } .onChange(of: envInputs) { _, new in viewModel.applyEnvInputs(new) } diff --git a/MC1/Views/Chats/Components/IncomingAvatarFlight.swift b/MC1/Views/Chats/Components/IncomingAvatarFlight.swift new file mode 100644 index 000000000..fb1250a52 --- /dev/null +++ b/MC1/Views/Chats/Components/IncomingAvatarFlight.swift @@ -0,0 +1,167 @@ +import MC1Services +import SwiftUI + +/// Owns the in-flight cluster-end avatar. Gutters read flying IDs only; the +/// overlay reads frames so scroll-time `reportFrame` does not invalidate rows. +@Observable +@MainActor +final class IncomingAvatarFlight { + struct Flight: Equatable { + let fromID: UUID + let toID: UUID + let identity: IncomingAvatarIdentity + let initialFromFrame: CGRect + } + + /// Skip the drop when the conversation is scrolled up. Default matches a + /// freshly opened thread sitting at the bottom. + var isAtBottom = true + + /// Skip the drop under Reduce Motion; the in-cell avatar swaps instantly. + var reduceMotion = false + + private(set) var flight: Flight? + private(set) var flyingFromID: UUID? + private(set) var flyingToID: UUID? + private var frames: [UUID: CGRect] = [:] + + func isFlying(_ id: UUID) -> Bool { + id == flyingFromID || id == flyingToID + } + + func shouldReportFrame(messageID: UUID, hasIdentity: Bool) -> Bool { + if flyingFromID != nil { + return messageID == flyingFromID || messageID == flyingToID + } + return hasIdentity + } + + func frame(for id: UUID) -> CGRect? { + frames[id] + } + + func drawnFrame(progress: CGFloat) -> CGRect? { + guard let flight else { return nil } + let from = frames[flight.fromID] ?? flight.initialFromFrame + let to = frames[flight.toID] ?? from + return Self.lerp(from: from, to: to, progress: progress) + } + + func beginFlight(from: UUID, to: UUID, identity: IncomingAvatarIdentity) { + mutateWithoutAnimation { + if flight != nil { + clearFlight() + return + } + guard isAtBottom, !reduceMotion else { return } + guard let fromFrame = frames[from] else { return } + flyingFromID = from + flyingToID = to + flight = Flight( + fromID: from, + toID: to, + identity: identity, + initialFromFrame: fromFrame + ) + } + } + + func reportFrame(_ frame: CGRect, for id: UUID) { + mutateWithoutAnimation { + frames[id] = frame + } + } + + func unbind(_ id: UUID) { + mutateWithoutAnimation { + frames.removeValue(forKey: id) + } + } + + func complete() { + mutateWithoutAnimation { + clearFlight() + } + } + + func overlay() -> some View { + Overlay(controller: self) + } + + static func lerp(from: CGRect, to: CGRect, progress: CGFloat) -> CGRect { + let p = min(max(progress, 0), 1) + return CGRect( + x: from.origin.x + (to.origin.x - from.origin.x) * p, + y: from.origin.y + (to.origin.y - from.origin.y) * p, + width: from.size.width + (to.size.width - from.size.width) * p, + height: from.size.height + (to.size.height - from.size.height) * p + ) + } + + private func clearFlight() { + if let from = flyingFromID { + frames.removeValue(forKey: from) + } + flyingFromID = nil + flyingToID = nil + flight = nil + } + + private func mutateWithoutAnimation(_ body: () -> Void) { + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction, body) + } +} + +extension EnvironmentValues { + @Entry var incomingAvatarFlight: IncomingAvatarFlight? +} + +extension IncomingAvatarFlight { + private struct Overlay: View { + var controller: IncomingAvatarFlight + @State private var progress: CGFloat = 0 + + var body: some View { + Group { + if let flight = controller.flight { + GeometryReader { geo in + let overlayGlobal = geo.frame(in: .global) + if let drawn = controller.drawnFrame(progress: progress) { + let local = drawn.offsetBy(dx: -overlayGlobal.minX, dy: -overlayGlobal.minY) + ContactAvatar( + name: flight.identity.name, + size: IncomingBubbleAvatarMetrics.size, + imageData: IncomingAvatarJPEGStore.data(for: flight.identity.matchedContactID) + ) + .frame( + width: IncomingBubbleAvatarMetrics.size, + height: IncomingBubbleAvatarMetrics.size + ) + .offset(x: local.minX, y: local.minY) + .accessibilityHidden(true) + .allowsHitTesting(false) + } + } + } + } + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityHidden(true) + .onChange(of: controller.flight?.toID, initial: true) { _, toID in + var reset = Transaction() + reset.animation = nil + withTransaction(reset) { progress = 0 } + guard let toID else { return } + withAnimation(.smooth(duration: IncomingBubbleAvatarMetrics.flightDuration)) { + progress = 1 + } completion: { + if controller.flight?.toID == toID { + controller.complete() + } + } + } + } + } +} diff --git a/MC1/Views/Chats/Components/IncomingAvatarGutter.swift b/MC1/Views/Chats/Components/IncomingAvatarGutter.swift index 916ff012e..6c1f466a0 100644 --- a/MC1/Views/Chats/Components/IncomingAvatarGutter.swift +++ b/MC1/Views/Chats/Components/IncomingAvatarGutter.swift @@ -4,24 +4,64 @@ import SwiftUI struct IncomingAvatarGutter: View { let identity: IncomingAvatarIdentity? let reserveColumn: Bool + let messageID: UUID + @Environment(\.incomingAvatarFlight) private var flight @ViewBuilder var content: Content var body: some View { - if let identity { - HStack(alignment: .bottom, spacing: IncomingBubbleAvatarMetrics.gap) { - ContactAvatar( - name: identity.name, - size: IncomingBubbleAvatarMetrics.size, - imageData: IncomingAvatarJPEGStore.data(for: identity.matchedContactID) - ) - .accessibilityHidden(true) + let isFlying = flight?.isFlying(messageID) == true + let shouldReport = flight?.shouldReportFrame( + messageID: messageID, + hasIdentity: identity != nil + ) == true + + Group { + if let identity { + HStack(alignment: .bottom, spacing: IncomingBubbleAvatarMetrics.gap) { + ContactAvatar( + name: identity.name, + size: IncomingBubbleAvatarMetrics.size, + imageData: IncomingAvatarJPEGStore.data(for: identity.matchedContactID) + ) + .opacity(isFlying ? 0 : 1) + .accessibilityHidden(true) + .onGeometryChange(for: CGRect.self) { proxy in + shouldReport ? proxy.frame(in: .global) : .null + } action: { global in + guard shouldReport, global != .null else { return } + flight?.reportFrame(global, for: messageID) + } + content + } + .contentShape(.rect) + } else if reserveColumn { + content + .padding(.leading, IncomingBubbleAvatarMetrics.columnWidth) + .background(alignment: .bottomLeading) { + if shouldReport { + Color.clear + .frame( + width: IncomingBubbleAvatarMetrics.size, + height: IncomingBubbleAvatarMetrics.size + ) + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { global in + flight?.reportFrame(global, for: messageID) + } + } + } + } else { content } - .contentShape(.rect) - } else if reserveColumn { - content.padding(.leading, IncomingBubbleAvatarMetrics.columnWidth) - } else { - content + } + .onDisappear { flight?.unbind(messageID) } + .onChange(of: messageID) { oldID, _ in + flight?.unbind(oldID) + } + .onChange(of: identity) { _, newIdentity in + guard newIdentity == nil, flight?.isFlying(messageID) != true else { return } + flight?.unbind(messageID) } } } diff --git a/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift b/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift index b54faca78..56f05316e 100644 --- a/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift +++ b/MC1/Views/Chats/Components/IncomingBubbleAvatarMetrics.swift @@ -1,8 +1,10 @@ import CoreGraphics +import Foundation enum IncomingBubbleAvatarMetrics { static let size: CGFloat = 28 static let gap: CGFloat = 6 + static let flightDuration: TimeInterval = 0.25 static var columnWidth: CGFloat { size + gap } diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index be8cfc5a8..f721b5f07 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -253,7 +253,8 @@ struct UnifiedMessageBubble: View, Equatable { private var bubbleWithOptionalAvatar: some View { IncomingAvatarGutter( identity: showAvatar ? item.envelope.incomingAvatar : nil, - reserveColumn: reserveAvatarColumn + reserveColumn: reserveAvatarColumn, + messageID: message.id ) { stackedBubble } diff --git a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift index 0391ea94b..ed343ef10 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline+Paging.swift @@ -188,16 +188,32 @@ extension ChatTimeline { // MARK: - Admission + /// Result of `admit`: whether the row was inserted, and the same-sender + /// cluster-end pair when the previous last incoming avatar should drop onto + /// the new tail. + struct Admission: Equatable { + struct Handoff: Equatable, Sendable { + let fromID: UUID + let toID: UUID + let identity: IncomingAvatarIdentity + } + + let inserted: Bool + let handoff: Handoff? + + static let ignored = Admission(inserted: false, handoff: nil) + } + /// Admits a message into the open timeline: dedupes against the loaded /// window and appends the message and its baked render item in one call /// frame, so the row lands already carrying its preview fragment. Returns - /// false when the message was already present or the timeline is unbound + /// `.ignored` when the message was already present or the timeline is unbound /// (a stale writer drops the append at the coordinator). @discardableResult - func admit(_ message: MessageDTO) -> Bool { - guard coordinator != nil, let writer else { return false } + func admit(_ message: MessageDTO) -> Admission { + guard coordinator != nil, let writer else { return .ignored } let previous = messages.last - guard writer.append(message) else { return false } + guard writer.append(message) else { return .ignored } let newItem = makeItem(for: message, previous: previous, next: nil) let shouldHandoff = previous.map { ChatMessageBakeState.incomingClusterContinues(from: $0, to: message) @@ -211,7 +227,16 @@ extension ChatTimeline { } return next.appendingItem(newItem) } - return true + let handoff: Admission.Handoff? = if shouldHandoff, let previous, let identity = newItem.envelope.incomingAvatar { + Admission.Handoff( + fromID: previous.id, + toID: message.id, + identity: identity + ) + } else { + nil + } + return Admission(inserted: true, handoff: handoff) } // MARK: - Message mutations diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift index f5c60b1e4..a11d0d9df 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+DisplayItems.swift @@ -15,7 +15,15 @@ extension ChatViewModel { /// invalidates dependent views once per change cycle without an explicit /// transaction. func appendMessageIfNew(_ message: MessageDTO) { - guard timeline.admit(message) else { return } + let admission = timeline.admit(message) + guard admission.inserted else { return } + if let handoff = admission.handoff { + incomingAvatarFlight?.beginFlight( + from: handoff.fromID, + to: handoff.toID, + identity: handoff.identity + ) + } if let senderName = message.senderNodeName, let radioID = currentChannel?.radioID { addChannelSenderIfNew(senderName, radioID: radioID, timestamp: message.timestamp) diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel.swift b/MC1/Views/Chats/ViewModel/ChatViewModel.swift index 56b44874f..7c6fa6f56 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel.swift @@ -83,6 +83,10 @@ final class ChatViewModel { /// Cancel-and-replace token for the serialized reload funnel. No view reads it. @ObservationIgnored var reloadTask: Task? + /// View-owned drop animation. Weak so a disappeared conversation cannot + /// start a flight after its overlay is gone. + @ObservationIgnored weak var incomingAvatarFlight: IncomingAvatarFlight? + #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. diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index 28d18cae1..623231988 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -207,6 +207,7 @@ struct RoomConversationView: View { private func makeMessagesView() -> some View { MessagesView( + viewModel: viewModel, hasLoadedOnce: viewModel.hasLoadedOnce, messages: viewModel.messages, isAtBottom: $isAtBottom, @@ -307,6 +308,7 @@ extension RoomConversationView { // MARK: - Messages View private struct MessagesView: View { + var viewModel: RoomConversationViewModel let hasLoadedOnce: Bool let messages: [RoomMessageDTO] @Binding var isAtBottom: Bool @@ -318,6 +320,8 @@ private struct MessagesView: View { let onLongPress: (RoomMessageDTO) -> Void @Environment(\.openURL) private var openURL + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var incomingAvatarFlight = IncomingAvatarFlight() var body: some View { Group { @@ -339,6 +343,7 @@ private struct MessagesView: View { ) .environment(\.appTheme, theme) .environment(\.openURL, openURL) + .environment(\.incomingAvatarFlight, incomingAvatarFlight) }, contentBackground: theme.surfaces?.canvas, isAtBottom: $isAtBottom, @@ -346,6 +351,20 @@ private struct MessagesView: View { scrollToBottomRequest: scrollToBottomRequest, countsTowardUnread: { !$0.message.isFromSelf } ) + .overlay { + incomingAvatarFlight.overlay() + } + .onAppear { + viewModel.incomingAvatarFlight = incomingAvatarFlight + incomingAvatarFlight.isAtBottom = isAtBottom + incomingAvatarFlight.reduceMotion = reduceMotion + } + .onChange(of: isAtBottom, initial: true) { _, atBottom in + incomingAvatarFlight.isAtBottom = atBottom + } + .onChange(of: reduceMotion, initial: true) { _, reduce in + incomingAvatarFlight.reduceMotion = reduce + } } } .themedCanvas(theme) diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift index 368e537ff..4400db245 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift @@ -60,6 +60,10 @@ final class RoomConversationViewModel { /// state still feels fresh. private static let reloadDebounce: Duration = .milliseconds(50) + /// View-owned drop animation. Weak so a disappeared room cannot start a + /// flight after its overlay is gone. + @ObservationIgnored weak var incomingAvatarFlight: IncomingAvatarFlight? + // MARK: - Initialization init() {} @@ -113,8 +117,19 @@ final class RoomConversationViewModel { /// `[timestamp, createdAt]` sort. func appendMessageIfNew(_ message: RoomMessageDTO) { guard !messages.contains(where: { $0.id == message.id }) else { return } + let previousTail = messages.last let index = messages.firstIndex { $0.timestamp > message.timestamp } ?? messages.endIndex + let isTailAppend = index == messages.endIndex messages.insert(message, at: index) + if isTailAppend, + let previous = previousTail, + Self.incomingClusterContinues(from: previous, to: message) { + incomingAvatarFlight?.beginFlight( + from: previous.id, + to: message.id, + identity: .initials(name: message.authorDisplayName) + ) + } } /// Send a message to the current room diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift index 3d8696845..0fbf172d0 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift @@ -186,7 +186,8 @@ private struct BubbleContent: View { IncomingAvatarGutter( identity: showAvatar ? .initials(name: message.authorDisplayName) : nil, - reserveColumn: !isFromSelf + reserveColumn: !isFromSelf, + messageID: message.id ) { messageBox } diff --git a/MC1Tests/Views/Chats/ChatTimelineTests.swift b/MC1Tests/Views/Chats/ChatTimelineTests.swift index 05a1db707..19ad56e0a 100644 --- a/MC1Tests/Views/Chats/ChatTimelineTests.swift +++ b/MC1Tests/Views/Chats/ChatTimelineTests.swift @@ -278,7 +278,7 @@ struct ChatTimelineTests { _ = await timeline.open(.dm(contact), reactions: nil, populateMode: .replace) let message = makeDirectMessage(radioID: radioID, contactID: contact.id, timestamp: 2000, text: "hello") - #expect(timeline.admit(message)) + #expect(timeline.admit(message).inserted) #expect(timeline.messages.last?.id == message.id) #expect(timeline.items.last?.id == message.id) @@ -299,7 +299,7 @@ struct ChatTimelineTests { _ = await timeline.open(.dm(contact), reactions: nil, populateMode: .replace) #expect(timeline.messages.count == 1) - #expect(!timeline.admit(seeded)) + #expect(timeline.admit(seeded).inserted == false) #expect(timeline.messages.count == 1) } @@ -307,7 +307,7 @@ struct ChatTimelineTests { func `admit no-ops on an unbound timeline`() { let timeline = ChatTimeline(role: .interactive) let message = makeDirectMessage(radioID: UUID(), contactID: UUID(), timestamp: 1000, text: "x") - #expect(!timeline.admit(message)) + #expect(timeline.admit(message).inserted == false) #expect(timeline.messages.isEmpty) } diff --git a/MC1Tests/Views/Chats/Components/IncomingAvatarFlightTests.swift b/MC1Tests/Views/Chats/Components/IncomingAvatarFlightTests.swift new file mode 100644 index 000000000..08cf76c6b --- /dev/null +++ b/MC1Tests/Views/Chats/Components/IncomingAvatarFlightTests.swift @@ -0,0 +1,565 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import SwiftUI +import Testing +import UIKit + +@Suite("Incoming avatar flight") +@MainActor +struct IncomingAvatarFlightTests { + @Test + func `same-sender admit starts a flight before the next run loop`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + let fromFrame = CGRect(x: 12, y: 500, width: 28, height: 28) + + viewModel.appendMessageIfNew(first) + flight.reportFrame(fromFrame, for: first.id) + viewModel.appendMessageIfNew(second) + + #expect(flight.flight?.fromID == first.id) + #expect(flight.flight?.toID == second.id) + #expect(flight.isFlying(second.id)) + #expect(flight.drawnFrame(progress: 0)?.origin == fromFrame.origin) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `beginFlight uses the continued cluster-end, not an older cluster`() { + let flight = IncomingAvatarFlight() + let a = UUID() + let b = UUID() + let c = UUID() + let d = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: a) + flight.reportFrame(CGRect(x: 0, y: 400, width: 28, height: 28), for: c) + + flight.beginFlight(from: c, to: d, identity: .initials(name: "Alice")) + + #expect(flight.flight?.fromID == c) + #expect(flight.flight?.toID == d) + #expect(flight.isFlying(a) == false) + #expect(flight.isFlying(b) == false) + } + + @Test + func `scrolled-up admit does not start a flight`() { + let flight = IncomingAvatarFlight() + flight.isAtBottom = false + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + viewModel.appendMessageIfNew(first) + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: first.id) + viewModel.appendMessageIfNew(second) + + #expect(flight.flight == nil) + #expect(viewModel.items[0].envelope.incomingAvatar == nil) + #expect(viewModel.items[1].envelope.incomingAvatar != nil) + } + + @Test + func `missing from-frame does not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + + #expect(flight.flight == nil) + } + + @Test + func `rebake that keeps the tail id does not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + flight.complete() + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: second.id) + + viewModel.buildItems() + + #expect(flight.flight == nil) + #expect(viewModel.items.last?.id == second.id) + } + + @Test + func `status preview and photo patches do not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + viewModel.appendMessageIfNew(first) + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: first.id) + + viewModel.timeline.applyStatusUpdate(messageID: first.id, status: .delivered) + #expect(flight.flight == nil) + + viewModel.timeline.writer?.updateRenderItem(id: first.id) { item in + item.with(envelope: item.envelope.with(incomingAvatar: .initials(name: "Alice"))) + } + #expect(flight.flight == nil) + + viewModel.timeline.writer?.updateRenderItem(id: first.id) { item in + item.with( + envelope: item.envelope.with( + incomingAvatar: IncomingAvatarIdentity( + name: "Alice", + matchedContactID: UUID(), + imageRevision: 1 + ) + ) + ) + } + #expect(flight.flight == nil) + } + + @Test + func `removing a cluster-end does not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = boundViewModel(flight: flight) + let first = createChannelMessage(timestamp: 1000, senderName: "Alice") + let second = createChannelMessage(timestamp: 1060, senderName: "Alice") + viewModel.appendMessageIfNew(first) + flight.reportFrame(CGRect(x: 0, y: 400, width: 28, height: 28), for: first.id) + viewModel.appendMessageIfNew(second) + flight.complete() + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: second.id) + + viewModel.timeline.removeMessage(second.id) + + #expect(flight.flight == nil) + #expect(viewModel.items[0].envelope.incomingAvatar != nil) + } + + @Test + func `unbind drops the stored frame so a later hop cannot use it`() { + let flight = IncomingAvatarFlight() + let a = UUID() + let c = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: a) + flight.unbind(a) + + #expect(flight.frame(for: a) == nil) + flight.beginFlight(from: a, to: c, identity: .initials(name: "Alice")) + #expect(flight.flight == nil) + } + + @Test + func `identity strip without a flight unbinds the stored frame`() { + let flight = IncomingAvatarFlight() + let messageID = UUID() + let holder = GutterIdentityHolder(identity: .initials(name: "Alice")) + let host = UIHostingController( + rootView: IdentityStripHost(holder: holder, messageID: messageID) + .environment(\.incomingAvatarFlight, flight) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: messageID) + + holder.identity = nil + window.layoutIfNeeded() + + #expect(flight.frame(for: messageID) == nil) + } + + @Test + func `identity strip during flight keeps the from-frame`() { + let flight = IncomingAvatarFlight() + let from = UUID() + let to = UUID() + let holder = GutterIdentityHolder(identity: .initials(name: "Alice")) + let host = UIHostingController( + rootView: IdentityStripHost(holder: holder, messageID: from) + .environment(\.incomingAvatarFlight, flight) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + let fromFrame = CGRect(x: 0, y: 100, width: 28, height: 28) + flight.reportFrame(fromFrame, for: from) + flight.beginFlight(from: from, to: to, identity: .initials(name: "Alice")) + + holder.identity = nil + window.layoutIfNeeded() + + #expect(flight.isFlying(from)) + #expect(flight.frame(for: from) != nil) + } + + @Test + func `rebind reports the new id and clears the old`() { + let flight = IncomingAvatarFlight() + let a = UUID() + let b = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: a) + flight.unbind(a) + flight.reportFrame(CGRect(x: 0, y: 200, width: 28, height: 28), for: b) + + #expect(flight.frame(for: a) == nil) + #expect(flight.frame(for: b)?.origin.y == 200) + } + + @Test + func `at rest only cluster-ends report; during flight only from and to`() { + let flight = IncomingAvatarFlight() + let ids = (0..<12).map { _ in UUID() } + let from = ids[10] + let to = ids[11] + + for id in ids { + #expect(flight.shouldReportFrame(messageID: id, hasIdentity: id == from) == (id == from)) + } + + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: from) + flight.beginFlight(from: from, to: to, identity: .initials(name: "Alice")) + + for id in ids { + let expected = id == from || id == to + #expect(flight.shouldReportFrame(messageID: id, hasIdentity: id == to) == expected) + } + + flight.complete() + for id in ids { + #expect(flight.shouldReportFrame(messageID: id, hasIdentity: id == to) == (id == to)) + } + } + + @Test + func `reportFrame does not invalidate a gutter that only reads the flying flag`() { + let flight = IncomingAvatarFlight() + let probeID = UUID() + let counter = BodyCounter() + let host = UIHostingController( + rootView: FlyingFlagProbe(messageID: probeID, counter: counter) + .environment(\.incomingAvatarFlight, flight) + ) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 200)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + let afterAppear = counter.count + + let noise = UUID() + for index in 0..<30 { + flight.reportFrame( + CGRect(x: 0, y: CGFloat(index), width: 28, height: 28), + for: noise + ) + } + window.layoutIfNeeded() + + #expect(counter.count == afterAppear) + } + + @Test + func `overlapping beginFlight cancels instead of splicing`() { + let flight = IncomingAvatarFlight() + let a = UUID() + let b = UUID() + let c = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: a) + flight.beginFlight(from: a, to: b, identity: .initials(name: "Alice")) + flight.reportFrame(CGRect(x: 0, y: 180, width: 28, height: 28), for: b) + flight.beginFlight(from: b, to: c, identity: .initials(name: "Alice")) + + #expect(flight.flight == nil) + #expect(flight.isFlying(a) == false) + #expect(flight.isFlying(b) == false) + } + + @Test + func `drawn frame lerps live frames not a captured snapshot`() { + let flight = IncomingAvatarFlight() + let from = UUID() + let to = UUID() + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: from) + flight.beginFlight(from: from, to: to, identity: .initials(name: "Alice")) + flight.reportFrame(CGRect(x: 0, y: 600, width: 28, height: 28), for: to) + + #expect(flight.drawnFrame(progress: 0.5)?.origin.y == 550) + + flight.reportFrame(CGRect(x: 0, y: 400, width: 28, height: 28), for: from) + flight.reportFrame(CGRect(x: 0, y: 500, width: 28, height: 28), for: to) + + #expect(flight.drawnFrame(progress: 0.5)?.origin.y == 450) + } + + @Test + func `reserved empty gutter keeps padding-only height`() { + let messageID = UUID() + let gutterHeight = measureHeight( + IncomingAvatarGutter(identity: nil, reserveColumn: true, messageID: messageID) { + Text("Hi") + } + ) + let paddedHeight = measureHeight( + Text("Hi").padding(.leading, IncomingBubbleAvatarMetrics.columnWidth) + ) + #expect(gutterHeight == paddedHeight) + } + + @Test + func `rooms fly the matching prefix cluster only`() { + let flight = IncomingAvatarFlight() + let viewModel = RoomConversationViewModel() + viewModel.incomingAvatarFlight = flight + let aa = Data([0xAA]) + let bb = Data([0xBB]) + let firstBB = roomMessage(ts: 100, prefix: bb, name: "Alice") + let firstAA = roomMessage(ts: 160, prefix: aa, name: "Alice") + let followAA = roomMessage(ts: 220, prefix: aa, name: "Alice") + + viewModel.appendMessageIfNew(firstBB) + viewModel.appendMessageIfNew(firstAA) + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: firstBB.id) + flight.reportFrame(CGRect(x: 0, y: 200, width: 28, height: 28), for: firstAA.id) + viewModel.appendMessageIfNew(followAA) + + #expect(flight.flight?.fromID == firstAA.id) + #expect(flight.flight?.toID == followAA.id) + #expect(flight.isFlying(firstBB.id) == false) + } + + @Test + func `coalesced room loadMessages replace does not start a flight`() { + let flight = IncomingAvatarFlight() + let viewModel = RoomConversationViewModel() + viewModel.incomingAvatarFlight = flight + let prefix = Data([0xAA]) + let first = roomMessage(ts: 100, prefix: prefix, name: "Alice") + let second = roomMessage(ts: 160, prefix: prefix, name: "Alice") + viewModel.appendMessageIfNew(first) + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: first.id) + viewModel.appendMessageIfNew(second) + flight.complete() + flight.reportFrame(CGRect(x: 0, y: 200, width: 28, height: 28), for: second.id) + + viewModel.messages = [first, second] + + #expect(flight.flight == nil) + } + + @Test + func `overlay hides the flying avatar from accessibility`() { + let flight = IncomingAvatarFlight() + let from = UUID() + let to = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: from) + flight.beginFlight(from: from, to: to, identity: .initials(name: "Alice")) + + let overlayHost = UIHostingController(rootView: flight.overlay().frame(width: 320, height: 400)) + let visibleHost = UIHostingController( + rootView: ContactAvatar(name: "Alice", size: IncomingBubbleAvatarMetrics.size) + .frame(width: 320, height: 400) + ) + let overlayWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 400)) + overlayWindow.rootViewController = overlayHost + overlayWindow.isHidden = false + overlayWindow.layoutIfNeeded() + let visibleWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 400)) + visibleWindow.rootViewController = visibleHost + visibleWindow.isHidden = false + visibleWindow.layoutIfNeeded() + + let overlayLabels = accessibilityLabels(in: overlayHost.view) + let visibleLabels = accessibilityLabels(in: visibleHost.view) + #expect(visibleLabels.contains { $0 == "A" || $0.localizedCaseInsensitiveContains("Alice") }) + #expect(overlayLabels.contains { $0 == "A" || $0.localizedCaseInsensitiveContains("Alice") } == false) + } + + @Test + func `cell wrap injects the flight environment; nil default still builds`() { + let flight = IncomingAvatarFlight() + let box = FlightBox() + let wrapped = EnvironmentProbe(box: box) + .environment(\.incomingAvatarFlight, flight) + let host = UIHostingController(rootView: wrapped) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + #expect(box.value != nil) + + let gutter = IncomingAvatarGutter( + identity: .initials(name: "Alice"), + reserveColumn: true, + messageID: UUID() + ) { + Text("Hi") + } + let nilHost = UIHostingController(rootView: gutter) + let nilWindow = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) + nilWindow.rootViewController = nilHost + nilWindow.isHidden = false + nilWindow.layoutIfNeeded() + #expect(nilHost.view != nil) + } + + @Test + func `reduce motion skips beginFlight`() { + let flight = IncomingAvatarFlight() + flight.reduceMotion = true + let from = UUID() + flight.reportFrame(CGRect(x: 0, y: 100, width: 28, height: 28), for: from) + flight.beginFlight(from: from, to: UUID(), identity: .initials(name: "Alice")) + #expect(flight.flight == nil) + } +} + +@MainActor +private func boundViewModel(flight: IncomingAvatarFlight) -> ChatViewModel { + let viewModel = ChatViewModel() + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + viewModel.incomingAvatarFlight = flight + return viewModel +} + +private func createChannelMessage( + timestamp: UInt32, + senderName: String? = nil +) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: nil, + channelIndex: 0, + text: "Test message", + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + 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 roomMessage( + ts: UInt32, + prefix: Data, + name: String +) -> RoomMessageDTO { + RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: prefix, + authorName: name, + text: "msg", + timestamp: ts + ) +} + +@MainActor +private func measureHeight(_ view: some View) -> CGFloat { + let host = UIHostingController(rootView: view) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 800)) + window.rootViewController = host + window.isHidden = false + window.layoutIfNeeded() + return host.sizeThatFits(in: CGSize(width: 390, height: 800)).height +} + +@MainActor +private func accessibilityLabels(in view: UIView) -> [String] { + var labels: [String] = [] + if view.isAccessibilityElement, let label = view.accessibilityLabel, !label.isEmpty { + labels.append(label) + } + if let elements = view.accessibilityElements { + for element in elements { + guard let object = element as? NSObject, + let label = object.accessibilityLabel, + !label.isEmpty else { continue } + labels.append(label) + } + } + for subview in view.subviews { + labels.append(contentsOf: accessibilityLabels(in: subview)) + } + return labels +} + +private final class BodyCounter { + var count = 0 +} + +private struct FlyingFlagProbe: View { + let messageID: UUID + let counter: BodyCounter + @Environment(\.incomingAvatarFlight) private var flight + + var body: some View { + Text(flight?.isFlying(messageID) == true ? "flying" : "rest") + .background(BodyTick(counter: counter)) + } +} + +private struct BodyTick: View { + let counter: BodyCounter + + var body: Color { + counter.count += 1 + return Color.clear + } +} + +private final class FlightBox { + var value: IncomingAvatarFlight? +} + +@Observable +@MainActor +private final class GutterIdentityHolder { + var identity: IncomingAvatarIdentity? + + init(identity: IncomingAvatarIdentity?) { + self.identity = identity + } +} + +private struct IdentityStripHost: View { + let holder: GutterIdentityHolder + let messageID: UUID + + var body: some View { + IncomingAvatarGutter( + identity: holder.identity, + reserveColumn: true, + messageID: messageID + ) { + Text("Hi") + } + } +} + +private struct EnvironmentProbe: View { + let box: FlightBox + @Environment(\.incomingAvatarFlight) private var flight + + var body: some View { + Color.clear + .onAppear { box.value = flight } + } +} From 5977b5045dcf209f0a13a33ba4bae07bc84667b2 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:06:15 -0700 Subject: [PATCH 23/47] feat(chats): plot channel sender on path map - Place pin A when a unique contact name matches the channel sender - Simulator channel mocks no longer invent a key prefix --- .../Chats/Components/MessagePathMapView.swift | 20 +- .../Components/MessagePathViewModel.swift | 22 +++ .../MockDataProvider+ChannelMessages.swift | 31 ++- .../MessagePathViewModelTests.swift | 177 +++++++++++++++++- 4 files changed, 222 insertions(+), 28 deletions(-) diff --git a/MC1/Views/Chats/Components/MessagePathMapView.swift b/MC1/Views/Chats/Components/MessagePathMapView.swift index e5bb8ce10..838cb8d0a 100644 --- a/MC1/Views/Chats/Components/MessagePathMapView.swift +++ b/MC1/Views/Chats/Components/MessagePathMapView.swift @@ -124,8 +124,13 @@ struct MessagePathMapView: View { Button(L10n.Localizable.Common.done) { dismiss() } } } + .navigationBarTitleDisplayMode(.inline) .onAppear { - locatedNodes = buildLocatedNodes() + rebuildLocatedNodes() + } + .onChange(of: pathViewModel.isLoading) { _, isLoading in + guard !isLoading else { return } + rebuildLocatedNodes() } .onChange(of: isStyleLoaded) { _, loaded in guard loaded, !hasInitiallyFit else { return } @@ -167,13 +172,16 @@ struct MessagePathMapView: View { cameraRegionVersion += 1 } - private func buildLocatedNodes() -> [(point: MapPoint, coordinate: CLLocationCoordinate2D)] { + private func rebuildLocatedNodes() { + locatedNodes = buildLocatedNodes(sender: pathViewModel.locatedSender(for: message)) + } + + private func buildLocatedNodes( + sender: ContactDTO? + ) -> [(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 { + if let sender { let coord = CLLocationCoordinate2D(latitude: sender.latitude, longitude: sender.longitude) nodes.append((MapPoint( id: sender.id, diff --git a/MC1/Views/Chats/Components/MessagePathViewModel.swift b/MC1/Views/Chats/Components/MessagePathViewModel.swift index aab3d619c..42fa4522c 100644 --- a/MC1/Views/Chats/Components/MessagePathViewModel.swift +++ b/MC1/Views/Chats/Components/MessagePathViewModel.swift @@ -70,6 +70,28 @@ final class MessagePathViewModel { return String(format: "%02X", firstByte) } + /// Pin A contact from a non-empty `senderKeyPrefix`, or a unique `senderNodeName` on a channel row. + func locatedSender(for message: MessageDTO) -> ContactDTO? { + if let keyPrefix = message.senderKeyPrefix, !keyPrefix.isEmpty { + guard let sender = contacts.first(where: { $0.publicKeyPrefix == keyPrefix }), + sender.hasLocation else { + return nil + } + return sender + } + + guard message.isChannelMessage, + let senderName = message.senderNodeName, !senderName.isEmpty else { + return nil + } + + let matches = SenderContactMatcher.filter(contacts: contacts, senderName: senderName) + guard matches.count == 1, let sender = matches.first, sender.hasLocation else { + return nil + } + return sender + } + func repeaterResolution(for hashBytes: Data, userLocation: CLLocation?) -> NodeNameResolution { NeighborNameResolver.resolve( for: hashBytes, diff --git a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift index cd68d4045..3260d6e58 100644 --- a/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift +++ b/MC1Services/Sources/MC1Services/Simulator/MockDataProvider+ChannelMessages.swift @@ -1,9 +1,8 @@ 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. + /// Mock channel rows for a slot index. Incoming messages use `channelIndex` and + /// `senderNodeName`; `senderKeyPrefix` stays nil, matching the MeshCore channel payload. public static func channelMessages(for index: UInt8) -> [MessageDTO] { let now = Date() switch index { @@ -24,9 +23,9 @@ extension MockDataProvider { private static func meshHQChannelMessages(now: Date) -> [MessageDTO] { let path = encodePathLen(hashSize: 1, hopCount: 2) - let senders: [(name: String, seed: UInt8)] = [ - ("Alice Chen", 10), ("Bob Martinez", 20), ("Carol Diaz", 90), - ("Frank Wilson", 60), ("Hannah Lee", 80) + let senders = [ + "Alice Chen", "Bob Martinez", "Carol Diaz", + "Frank Wilson", "Hannah Lee" ] let lines = [ "Morning net check — who's on frequency?", @@ -71,8 +70,7 @@ extension MockDataProvider { channelIndex: meshHQChannelIndex, pathLength: path, snr: 6.5 + Double(i % 5) * 0.4, - senderKeyPrefix: mockPublicKey(seed: sender.seed).prefix(6), - senderNodeName: sender.name, + senderNodeName: sender, isRead: isRead ) } @@ -91,20 +89,20 @@ extension MockDataProvider { "C0000000-0000-0000-0000-000000000001", now.addingTimeInterval(-9000), "Anyone monitoring the north repeater today?", - sender: "Alice Chen", keySeed: 10, snr: 7.4, path: path + sender: "Alice Chen", 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 + sender: "Alice Chen", 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 + sender: "Bob Martinez", snr: 6.9, path: path ), MockMessageFactory.message( id: UUID(uuidString: "C0000000-0000-0000-0000-000000000004")!, @@ -126,7 +124,6 @@ extension MockDataProvider { pathLength: path, snr: 6.2, pathNodes: Data([0x10, 0x20]), - senderKeyPrefix: mockPublicKey(seed: 10).prefix(6), senderNodeName: "Alice Chen", routeType: .tcFlood, regionScope: nil, @@ -143,21 +140,21 @@ extension MockDataProvider { "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 + sender: "Carol Diaz", 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 + sender: "Carol Diaz", 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, + sender: "Carol Diaz", snr: 8.8, path: path, isRead: false, containsSelfMention: true, mentionSeen: false ) ] @@ -171,7 +168,7 @@ extension MockDataProvider { "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 + sender: "Frank Wilson", snr: 5.2, path: path ), MockMessageFactory.message( id: UUID(uuidString: "C2000000-0000-0000-0000-000000000002")!, @@ -192,7 +189,6 @@ extension MockDataProvider { _ createdAt: Date, _ text: String, sender: String, - keySeed: UInt8, snr: Double, path: UInt8, isRead: Bool = true, @@ -210,7 +206,6 @@ extension MockDataProvider { channelIndex: index, pathLength: path, snr: snr, - senderKeyPrefix: mockPublicKey(seed: keySeed).prefix(6), senderNodeName: sender, isRead: isRead, containsSelfMention: containsSelfMention, diff --git a/MC1Tests/ViewModels/MessagePathViewModelTests.swift b/MC1Tests/ViewModels/MessagePathViewModelTests.swift index 88b01bc16..1096c7529 100644 --- a/MC1Tests/ViewModels/MessagePathViewModelTests.swift +++ b/MC1Tests/ViewModels/MessagePathViewModelTests.swift @@ -10,7 +10,10 @@ struct MessagePathViewModelTests { prefix: [UInt8], name: String, type: ContactType = .chat, - lastAdvertTimestamp: UInt32 = 0 + lastAdvertTimestamp: UInt32 = 0, + latitude: Double = 0, + longitude: Double = 0, + isBlocked: Bool = false ) -> ContactDTO { ContactDTO( id: UUID(), @@ -22,12 +25,12 @@ struct MessagePathViewModelTests { outPathLength: 0, outPath: Data(), lastAdvertTimestamp: lastAdvertTimestamp, - latitude: 0, - longitude: 0, + latitude: latitude, + longitude: longitude, lastModified: 0, lastHeardTimestamp: nil, nickname: nil, - isBlocked: false, + isBlocked: isBlocked, isMuted: false, isFavorite: false, lastMessageDate: nil, @@ -165,6 +168,172 @@ struct MessagePathViewModelTests { #expect(viewModel.senderNodeID(for: message) == "0A") } + // MARK: - locatedSender + + private static let locatedLatitude = 37.7749 + private static let locatedLongitude = -122.4194 + + @Test + func `locatedSender matches DM by key prefix`() { + let viewModel = MessagePathViewModel() + let contact = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "Alpha", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + viewModel.contacts = [contact] + + let result = viewModel.locatedSender(for: createMessage(senderKeyPrefix: contact.publicKeyPrefix)) + + #expect(result?.id == contact.id) + } + + @Test + func `locatedSender returns nil for DM prefix match without location`() { + let viewModel = MessagePathViewModel() + let contact = createContact(prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], name: "Alpha") + viewModel.contacts = [contact] + + let result = viewModel.locatedSender(for: createMessage(senderKeyPrefix: contact.publicKeyPrefix)) + + #expect(result == nil) + } + + @Test + func `locatedSender matches unique channel sender name with location`() { + let viewModel = MessagePathViewModel() + let contact = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "RemoteNode", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + viewModel.contacts = [contact] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "RemoteNode", channelIndex: 0) + ) + + #expect(result?.id == contact.id) + } + + @Test + func `locatedSender returns nil for unique channel name without location`() { + let viewModel = MessagePathViewModel() + let contact = createContact(prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], name: "RemoteNode") + viewModel.contacts = [contact] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "RemoteNode", channelIndex: 0) + ) + + #expect(result == nil) + } + + @Test + func `locatedSender returns nil when two contacts share the channel sender name`() { + let viewModel = MessagePathViewModel() + let located = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "Alice", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + let unlocated = createContact(prefix: [0xBB, 0x02, 0x00, 0x00, 0x00, 0x00], name: "Alice") + viewModel.contacts = [located, unlocated] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "Alice", channelIndex: 0) + ) + + #expect(result == nil) + } + + @Test + func `locatedSender returns nil when no contact matches the channel sender name`() { + let viewModel = MessagePathViewModel() + viewModel.contacts = [ + createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "Alpha", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + ] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "RemoteNode", channelIndex: 0) + ) + + #expect(result == nil) + } + + @Test + func `locatedSender prefers key prefix over channel name when prefix matches`() { + let viewModel = MessagePathViewModel() + let prefixed = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "Alpha", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + let named = createContact( + prefix: [0xBB, 0x02, 0x00, 0x00, 0x00, 0x00], + name: "RemoteNode", + latitude: 37.8, + longitude: -122.5 + ) + viewModel.contacts = [prefixed, named] + + let result = viewModel.locatedSender( + for: createMessage( + senderKeyPrefix: prefixed.publicKeyPrefix, + senderNodeName: "RemoteNode", + channelIndex: 0 + ) + ) + + #expect(result?.id == prefixed.id) + } + + @Test + func `locatedSender matches channel sender name case-insensitively`() { + let viewModel = MessagePathViewModel() + let contact = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "RemoteNode", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude + ) + viewModel.contacts = [contact] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "remotenode", channelIndex: 0) + ) + + #expect(result?.id == contact.id) + } + + @Test + func `locatedSender includes a unique blocked contact`() { + let viewModel = MessagePathViewModel() + let contact = createContact( + prefix: [0xAA, 0x01, 0x00, 0x00, 0x00, 0x00], + name: "RemoteNode", + latitude: Self.locatedLatitude, + longitude: Self.locatedLongitude, + isBlocked: true + ) + viewModel.contacts = [contact] + + let result = viewModel.locatedSender( + for: createMessage(senderKeyPrefix: nil, senderNodeName: "RemoteNode", channelIndex: 0) + ) + + #expect(result?.id == contact.id) + } + // MARK: - repeaterName @Test From 2cc95f5253c705c0ba098152022f59fe77441fd4 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:16:52 -0700 Subject: [PATCH 24/47] fix(settings): show contact info while typing - Bind the loaded field directly so typed text is visible - Show Apply errors on the Contact Info section - Keep the character count in the field row --- .../RemoteNodes/SharedNodeSettingsViews.swift | 49 +++-- ...NodeSettingsViewModelValidationTests.swift | 55 ++++++ .../NodeContactInfoSectionTests.swift | 177 ++++++++++++++++++ 3 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 MC1Tests/Views/RemoteNodes/NodeContactInfoSectionTests.swift diff --git a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift index 383f5be25..ff916ae2d 100644 --- a/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift +++ b/MC1/Views/RemoteNodes/SharedNodeSettingsViews.swift @@ -330,7 +330,6 @@ struct RemoteNodeIdentitySection: View { struct NodeContactInfoSection: View { @Bindable var settings: NodeSettingsViewModel var focusedField: FocusState.Binding - @State private var contactText = "" var body: some View { ExpandableSettingsSection( @@ -343,21 +342,47 @@ struct NodeContactInfoSection: View { 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) { + Fields(settings: settings, focusedField: focusedField) + } + } + + /// Separate view so `errorMessage` invalidates these rows, not only the + /// stored ExpandableSettingsSection content closure. + private struct Fields: View { + @Bindable var settings: NodeSettingsViewModel + var focusedField: FocusState.Binding + + var body: some View { + if settings.ownerInfo != nil { + VStack(alignment: .leading, spacing: 4) { + TextField( + L10n.RemoteNodes.RemoteNodes.Settings.contactInfoPlaceholder, + text: Binding( + get: { settings.ownerInfo ?? "" }, + set: { settings.ownerInfo = $0 } + ), + axis: .vertical + ) + .lineLimit(3...6) + .focused(focusedField, equals: .contactInfo) + 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 + .frame(maxWidth: .infinity, alignment: .trailing) } + } else { + SettingsLoadPlaceholder( + isLoading: settings.isLoadingContactInfo, + hasError: settings.contactInfoError + ) + } + + if let applyError = settings.errorMessage { + Text(applyError) + .foregroundStyle(.orange) + .font(.caption) + } Button { Task { await settings.applyContactInfoSettings() } diff --git a/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift b/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift index 4bee134f7..a2fdaed89 100644 --- a/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift +++ b/MC1Tests/ViewModels/NodeSettingsViewModelValidationTests.swift @@ -354,3 +354,58 @@ struct NodeSettingsClockSyncTests { #expect(viewModel.clockDrift == nil) } } + +@Suite("NodeSettingsViewModel contact info") +@MainActor +struct NodeSettingsContactInfoTests { + @MainActor + final class CommandRecorder { + private(set) var commands: [String] = [] + var reply = "OK" + func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { + commands.append(command) + return reply + } + } + + private func makeConfiguredViewModel(recorder: CommandRecorder) -> NodeSettingsViewModel { + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Node", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ) + let viewModel = NodeSettingsViewModel() + viewModel.configure(session: session, sendCommand: recorder.send, sendRawCommand: recorder.send) + return viewModel + } + + @Test + func `apply converts display newlines to the pipe wire form`() async { + let recorder = CommandRecorder() + let viewModel = makeConfiguredViewModel(recorder: recorder) + viewModel.setNodeInfo(firmwareVersion: "v1.17.1", name: "Repeater", ownerInfo: "KD7ABC") + viewModel.ownerInfo = "KD7ABC\nch 31" + + await viewModel.applyContactInfoSettings() + + #expect(recorder.commands == ["set owner.info KD7ABC|ch 31"]) + } + + @Test + func `apply failure sets errorMessage`() async { + let recorder = CommandRecorder() + recorder.reply = "ERR: not allowed" + let viewModel = makeConfiguredViewModel(recorder: recorder) + viewModel.setNodeInfo(firmwareVersion: "v1.17.1", name: "Repeater", ownerInfo: "old") + viewModel.ownerInfo = "new" + + await viewModel.applyContactInfoSettings() + + #expect(viewModel.errorMessage != nil) + #expect(viewModel.contactInfoApplySuccess == false) + #expect(viewModel.originalOwnerInfo == "old") + } +} diff --git a/MC1Tests/Views/RemoteNodes/NodeContactInfoSectionTests.swift b/MC1Tests/Views/RemoteNodes/NodeContactInfoSectionTests.swift new file mode 100644 index 000000000..d85dbbc49 --- /dev/null +++ b/MC1Tests/Views/RemoteNodes/NodeContactInfoSectionTests.swift @@ -0,0 +1,177 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import SwiftUI +import Testing +import UIKit + +/// Hosts the production Contact Info section and types through the real +/// UITextView so a binding or drawing failure matches the user's symptom: +/// typed characters missing while focused, and Apply sending a stale value. +@Suite("NodeContactInfoSection typing", .serialized) +@MainActor +struct NodeContactInfoSectionTests { + private static let existingInfo = "KD7ABC" + private static let typedSuffix = " extra" + private static let viewport = CGRect(x: 0, y: 0, width: 390, height: 844) + + private struct Harness: View { + @Bindable var settings: NodeSettingsViewModel + @FocusState var focusedField: NodeSettingsField? + + var body: some View { + NavigationStack { + Form { + NodeContactInfoSection(settings: settings, focusedField: $focusedField) + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(L10n.RemoteNodes.RemoteNodes.Settings.done) { + focusedField = nil + } + } + } + } + } + } + + @MainActor + final class CommandRecorder { + private(set) var commands: [String] = [] + var reply = "OK" + func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { + commands.append(command) + return reply + } + } + + @Test + func `typing into contact info updates ownerInfo while focused`() async throws { + let settings = makeLoadedSettings() + let (window, _) = mount(settings: settings) + defer { window.isHidden = true } + + let textView = try await waitForTextView(in: window) + textView.inputView = UIView() + #expect(textView.becomeFirstResponder()) + textView.insertText(Self.typedSuffix) + window.layoutIfNeeded() + + try await waitUntil("ownerInfo never picked up typed text") { + settings.ownerInfo == Self.existingInfo + Self.typedSuffix + } + #expect( + textView.text.contains(Self.typedSuffix), + "typed characters must be visible in the field while focused; after Done is too late" + ) + } + + @Test + func `contact info text color stays readable while the field is focused`() async throws { + let settings = makeLoadedSettings() + let (window, _) = mount(settings: settings) + defer { window.isHidden = true } + + let textView = try await waitForTextView(in: window) + let unfocusedColor = textView.textColor + textView.inputView = UIView() + #expect(textView.becomeFirstResponder()) + try await Task.sleep(for: .milliseconds(50)) + + let focusedColor = textView.textColor + let alpha = focusedColor?.cgColor.alpha ?? -1 + #expect( + alpha > 0.2, + "focused text color is invisible (unfocused=\(String(describing: unfocusedColor)), focused=\(String(describing: focusedColor)), bg=\(String(describing: textView.backgroundColor)))" + ) + #expect( + textView.bounds.height > 8, + "focused field collapsed to \(textView.bounds.size), which would hide typed glyphs" + ) + } + + @Test + func `apply after typing sends set owner.info with the edited value`() async throws { + let recorder = CommandRecorder() + let settings = makeLoadedSettings(recorder: recorder) + let (window, _) = mount(settings: settings) + defer { window.isHidden = true } + + let textView = try await waitForTextView(in: window) + textView.inputView = UIView() + #expect(textView.becomeFirstResponder()) + textView.insertText(Self.typedSuffix) + _ = textView.resignFirstResponder() + window.layoutIfNeeded() + + try await waitUntil("ownerInfo never committed after resign") { + settings.ownerInfo == Self.existingInfo + Self.typedSuffix + } + + await settings.applyContactInfoSettings() + + #expect(recorder.commands == ["set owner.info \(Self.existingInfo + Self.typedSuffix)"]) + } + + @Test + func `backspace on loaded contact info updates ownerInfo`() async throws { + let settings = makeLoadedSettings() + let (window, _) = mount(settings: settings) + defer { window.isHidden = true } + + let textView = try await waitForTextView(in: window) + textView.inputView = UIView() + #expect(textView.becomeFirstResponder()) + textView.deleteBackward() + window.layoutIfNeeded() + + try await waitUntil("backspace never reached ownerInfo") { + settings.ownerInfo == String(Self.existingInfo.dropLast()) + } + } + + private func makeLoadedSettings(recorder: CommandRecorder = CommandRecorder()) -> NodeSettingsViewModel { + let settings = NodeSettingsViewModel() + let session = RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Repeater", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ) + settings.configure(session: session, sendCommand: recorder.send, sendRawCommand: recorder.send) + settings.setNodeInfo(firmwareVersion: "v1.17.1", name: "Test Repeater", ownerInfo: Self.existingInfo) + settings.isContactInfoExpanded = true + return settings + } + + private func mount( + settings: NodeSettingsViewModel + ) -> (UIWindow, UIHostingController) { + let controller = UIHostingController(rootView: Harness(settings: settings)) + let window = UIWindow(frame: Self.viewport) + window.rootViewController = controller + window.isHidden = false + window.layoutIfNeeded() + return (window, controller) + } + + private func waitForTextView(in window: UIWindow) async throws -> UITextView { + var found: UITextView? + try await waitUntil("contact info UITextView never appeared") { + found = findTextView(in: window) + return found != nil + } + return try #require(found) + } + + private func findTextView(in view: UIView) -> UITextView? { + if let textView = view as? UITextView { return textView } + for subview in view.subviews { + if let found = findTextView(in: subview) { return found } + } + return nil + } +} From d5e44d21c5b73522b2b111c3ef4e38cb608dd85f Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:25:08 -0700 Subject: [PATCH 25/47] feat(l10n): add Portuguese (Portugal) --- MC1/Resources/AppShortcuts.xcstrings | 42 + .../Localization/pt.lproj/Chats.strings | 1239 ++++++++++++ .../Localization/pt.lproj/Contacts.strings | 1250 ++++++++++++ .../Localization/pt.lproj/Localizable.strings | 628 ++++++ .../pt.lproj/Localizable.stringsdict | 29 + .../Localization/pt.lproj/Map.strings | 193 ++ .../Localization/pt.lproj/Onboarding.strings | 325 ++++ .../Localization/pt.lproj/RemoteNodes.strings | 913 +++++++++ .../pt.lproj/RemoteNodes.stringsdict | 29 + .../Localization/pt.lproj/Settings.strings | 1729 +++++++++++++++++ .../pt.lproj/Settings.stringsdict | 126 ++ .../Localization/pt.lproj/Tools.strings | 866 +++++++++ .../Localization/pt.lproj/WhatsNew.strings | 31 + .../IntentErrorLocalizationTests.swift | 8 +- .../IntentMetadataLocalizationTests.swift | 8 +- .../Resources/pt.lproj/Localizable.strings | 16 + .../pt.lproj/Localizable.stringsdict | 43 + TRANSLATIONS.md | 3 +- 18 files changed, 7469 insertions(+), 9 deletions(-) create mode 100644 MC1/Resources/Localization/pt.lproj/Chats.strings create mode 100644 MC1/Resources/Localization/pt.lproj/Contacts.strings create mode 100644 MC1/Resources/Localization/pt.lproj/Localizable.strings create mode 100644 MC1/Resources/Localization/pt.lproj/Localizable.stringsdict create mode 100644 MC1/Resources/Localization/pt.lproj/Map.strings create mode 100644 MC1/Resources/Localization/pt.lproj/Onboarding.strings create mode 100644 MC1/Resources/Localization/pt.lproj/RemoteNodes.strings create mode 100644 MC1/Resources/Localization/pt.lproj/RemoteNodes.stringsdict create mode 100644 MC1/Resources/Localization/pt.lproj/Settings.strings create mode 100644 MC1/Resources/Localization/pt.lproj/Settings.stringsdict create mode 100644 MC1/Resources/Localization/pt.lproj/Tools.strings create mode 100644 MC1/Resources/Localization/pt.lproj/WhatsNew.strings create mode 100644 MC1Widgets/Resources/pt.lproj/Localizable.strings create mode 100644 MC1Widgets/Resources/pt.lproj/Localizable.stringsdict diff --git a/MC1/Resources/AppShortcuts.xcstrings b/MC1/Resources/AppShortcuts.xcstrings index 6058de8b0..ade3f0db0 100644 --- a/MC1/Resources/AppShortcuts.xcstrings +++ b/MC1/Resources/AppShortcuts.xcstrings @@ -41,6 +41,12 @@ "value" : "Wyślij ogłoszenie w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar um Advert em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -101,6 +107,12 @@ "value" : "Wyślij ogłoszenie ${reach} w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar um Advert ${reach} em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -161,6 +173,12 @@ "value" : "Sprawdź stan radia w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificar o estado do rádio em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -221,6 +239,12 @@ "value" : "Czy moje radio jest połączone w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "O meu rádio está ligado em ${applicationName}?" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -281,6 +305,12 @@ "value" : "Wyślij wiadomość do ${target} w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar uma mensagem a ${target} em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -341,6 +371,12 @@ "value" : "Wyślij wiadomość w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar uma mensagem em ${applicationName}" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -401,6 +437,12 @@ "value" : "Jaki jest poziom baterii radia w ${applicationName}" } }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Qual é a bateria do meu rádio em ${applicationName}?" + } + }, "ru" : { "stringUnit" : { "state" : "translated", diff --git a/MC1/Resources/Localization/pt.lproj/Chats.strings b/MC1/Resources/Localization/pt.lproj/Chats.strings new file mode 100644 index 000000000..4292646f1 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Chats.strings @@ -0,0 +1,1239 @@ +/* + Chats.strings + MC1 + + European Portuguese (pt-PT) translation of messaging UI strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Navigation & Titles + +/* Location: ChatsView.swift - Navigation title for main chat list */ +"chats.title" = "Chats"; + +/* Location: ChatsView.swift - Search placeholder */ +"chats.search.placeholder" = "Pesquisar conversas"; + +/* Location: ChatsView.swift - Filter option for all conversations */ +"chats.filter.all" = "Todas"; + +/* Location: ChatsView.swift - Filter menu title */ +"chats.filter.title" = "Filtrar"; + +/* Location: ChatsView.swift - Button to clear active filter */ +"chats.filter.clear" = "Limpar filtro"; + +/* Location: ChatsView.swift - Filter option for unread conversations */ +"chats.filter.unread" = "Por ler"; + +/* Location: ChatsView.swift - Filter option for direct messages */ +"chats.filter.directMessages" = "DMs"; + +/* Location: ChatsView.swift - Filter option for channels */ +"chats.filter.channels" = "Canais"; + +/* Location: ChatsView.swift - Filter option for rooms */ +"chats.filter.rooms" = "Salas"; + +// MARK: - Compose Menu + +/* Location: ChatsView.swift - Button to start a new direct chat */ +"chats.compose.newChat" = "Novo chat"; + +/* Location: ChatsView.swift - Button to create or join a channel */ +"chats.compose.newChannel" = "Novo canal"; + +/* Location: ChatsView.swift - Menu label for new message options */ +"chats.compose.newMessage" = "Nova mensagem"; + +// MARK: - Empty States + +/* Location: ChatsView.swift - Title when no conversations exist */ +"chats.emptyState.noConversations.title" = "Nenhuma conversa"; + +/* Location: ChatsView.swift - Description when no conversations exist */ +"chats.emptyState.noConversations.description" = "Inicie uma conversa a partir de Contactos"; + +/* Location: ChatsView.swift - Title when no unread messages */ +"chats.emptyState.noUnread.title" = "Sem mensagens por ler"; + +/* Location: ChatsView.swift - Description when no unread messages */ +"chats.emptyState.noUnread.description" = "Tudo lido"; + +/* Location: ChatsView.swift - Title when no direct messages */ +"chats.emptyState.noDirectMessages.title" = "Sem DMs"; + +/* Location: ChatsView.swift - Description when no direct messages */ +"chats.emptyState.noDirectMessages.description" = "Inicie um chat a partir de Contactos"; + +/* Location: ChatsView.swift - Title when no channels */ +"chats.emptyState.noChannels.title" = "Nenhum canal"; + +/* Location: ChatsView.swift - Description when no channels */ +"chats.emptyState.noChannels.description" = "Aderir ou criar um canal"; + +/* Location: ChatsView.swift - Title when no rooms exist */ +"chats.emptyState.noRooms.title" = "Nenhuma sala"; + +/* Location: ChatsView.swift - Description when no rooms exist */ +"chats.emptyState.noRooms.description" = "Entre numa sala a partir de Contactos"; + +/* Location: ChatsView.swift - Split view placeholder when no conversation selected */ +"chats.emptyState.selectConversation" = "Selecione uma conversa"; + +// MARK: - Refresh & Offline Alerts + +/* Location: ChatsView.swift - VoiceOver announcement when viewing cached data offline */ +"chats.accessibility.offlineAnnouncement" = "A visualizar dados em cache. Ligue o dispositivo para atualizar."; + +// MARK: - Room Deletion + +/* Location: ChatsView.swift - Alert title for leaving a room */ +"chats.alert.leaveRoom.title" = "Sair da sala"; + +/* Location: ChatsView.swift - Button to confirm leaving a room */ +"chats.alert.leaveRoom.confirm" = "Sair"; + +/* Location: ChatsView.swift - Alert message explaining what leaving a room does */ +"chats.alert.leaveRoom.message" = "Esta ação remove a sala da lista de chats, elimina todas as mensagens da sala e remove o contacto associado."; + +// MARK: - Message Timestamps + +/* Location: MessageDayDividerView.swift - Label for today's date in the chat day separator */ +/* Location: MessageDayDividerView.swift - Label for yesterday's date in the chat day separator */ +/* Location: RelativeTimestampText.swift - Timestamp for messages under 1 minute old */ +"chats.timestamp.now" = "Agora"; + +// MARK: - Scroll Buttons + +/* Location: ScrollToBottomButton.swift - Accessibility label for scroll to bottom button */ +"chats.scrollButton.scrollToBottom.accessibilityLabel" = "Ir para a mensagem mais recente"; + +/* Location: ScrollToBottomButton.swift - Accessibility value for unread message count - %d is the number of unread messages */ +"chats.scrollButton.scrollToBottom.accessibilityValue" = "%d mensagens por ler"; +/* Location: ScrollToMentionButton.swift, ScrollToBottomButton.swift - Badge text for 99+ unread */ +"chats.scrollButton.badge.overflow" = "99+"; + +// MARK: - Chat View (Direct Messages) + +/* Location: ChatConversationView.swift - Alert title when message send fails */ +"chats.alert.unableToSend.title" = "Não foi possível enviar"; + +/* Location: ChatConversationView.swift - Alert message when message send fails */ +"chats.alert.unableToSend.message" = "Certifique-se de que há uma ligação ao dispositivo e tente novamente."; + +/* Location: ChatConversationView.swift - Connection status for flood routed contacts */ +"chats.connectionStatus.floodRouting" = "Encaminhamento Flood"; + +/* Location: ChatConversationView.swift - Connection status format for direct path - %d is hop count */ +"chats.connectionStatus.direct" = "Direto • %d saltos"; + +/* Location: ChatConversationView.swift - Placeholder when message data is unavailable */ +"chats.message.unavailable" = "Mensagem indisponível"; + +/* Location: ChatConversationView.swift - Accessibility label for unavailable message */ +"chats.message.unavailableAccessibility" = "Não foi possível carregar a mensagem"; + +/* Location: ChatConversationView.swift - Empty state text prompting user to start chatting */ +"chats.emptyState.startConversation" = "Inicie uma conversa"; + +/* Location: ChatConversationView.swift - Label showing contact has location */ +"chats.contactInfo.hasLocation" = "Tem localização"; + +/* Location: ChatConversationView.swift - Input bar placeholder for direct messages */ +"chats.input.placeholder.directMessage" = "DM"; + +// MARK: - Channel Chat View + +/* Location: ChatConversationView.swift - Fallback channel name format - %d is channel index */ +"chats.channel.defaultName" = "Canal %d"; + +/* Location: ChatConversationView.swift - Header subtitle for public channels */ +"chats.channel.typePublic" = "Canal público"; + +/* Location: ChatConversationView.swift - Header subtitle for private channels */ +"chats.channel.typePrivate" = "Canal privado"; + +/* Location: ChatConversationType.swift - Channel header subtitle when a flood region is active - %@ is the region name */ +"chats.channel.headerRegion" = "Região: %@"; + +/* Location: ChatConversationView.swift - Empty state message */ +"chats.channel.emptyState.noMessages" = "Ainda sem mensagens"; + +/* Location: ChatConversationView.swift - Empty state description for public channel */ +"chats.channel.emptyState.publicDescription" = "Este é um canal público de transmissão"; + +/* Location: ChatConversationView.swift - Empty state description for private channel */ +"chats.channel.emptyState.privateDescription" = "Este é um canal privado"; + +// MARK: - Channel Info Sheet + +/* Location: ChannelInfoSheet.swift - Navigation title */ +/* Location: ChannelInfoSheet.swift - Confirmation dialog title */ +"chats.channelInfo.deleteConfirm.title" = "Eliminar canal"; + +/* Location: ChannelInfoSheet.swift - Confirmation dialog message */ +"chats.channelInfo.deleteConfirm.message" = "Esta ação remove o canal do dispositivo e elimina todas as mensagens locais. Esta ação não pode ser anulada."; + +/* Location: ChannelInfoSheet.swift - Channel type label for public channel */ +"chats.channelInfo.channelType.public" = "Canal público"; + +/* Location: ChannelInfoSheet.swift - Channel type label for hashtag channel */ +"chats.channelInfo.channelType.hashtag" = "Canal hashtag"; + +/* Location: ChannelInfoSheet.swift - Channel type label for private channel */ +"chats.channelInfo.channelType.private" = "Canal privado"; + +/* Location: ChannelInfoSheet.swift - Label for channel slot */ +/* Location: ChannelInfoSheet.swift - Label for last message date */ +/* Location: ChannelInfoSheet.swift - QR code instruction text */ +"chats.channelInfo.scanToJoin" = "Digitalize para aderir a este canal"; + +/* Location: ChannelInfoSheet.swift - Section header for QR sharing */ +"chats.channelInfo.shareChannel" = "Partilhar canal"; + +/* Location: ChannelInfoSheet.swift - Label for secret key */ +"chats.channelInfo.secretKey" = "Chave secreta"; + +/* Location: ChannelInfoSheet.swift - Button to copy secret key */ +"chats.channelInfo.copy" = "Copiar"; + +/* Location: ChannelInfoSheet.swift - Section header for manual sharing */ +"chats.channelInfo.manualSharing" = "Partilha manual"; + +/* Location: ChannelInfoSheet.swift - Footer explaining manual sharing */ +"chats.channelInfo.manualSharingFooter" = "Partilhe o nome do canal e esta chave secreta para que outros adiram manualmente."; + +/* Location: ChannelInfoSheet.swift - Clear messages button */ +"chats.channelInfo.clearMessagesButton" = "Limpar mensagens"; + +/* Location: ChannelInfoSheet.swift - Clear messages confirmation dialog title */ +"chats.channelInfo.clearMessagesConfirm.title" = "Limpar mensagens?"; + +/* Location: ChannelInfoSheet.swift - Clear messages confirmation dialog message */ +"chats.channelInfo.clearMessagesConfirm.message" = "Todas as mensagens deste canal serão eliminadas permanentemente. O canal permanece ativo."; + +/* Location: ChannelInfoSheet.swift - Delete channel button */ +"chats.channelInfo.deleteButton" = "Eliminar canal"; + +/* Location: ChannelInfoSheet.swift - Footer explaining delete action */ +"chats.channelInfo.deleteFooter" = "Eliminar remove este canal do dispositivo. É possível aderir novamente mais tarde se tiver a chave secreta."; + +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Falha ao eliminar o canal"; + +/* Location: ChannelInfoSheet.swift - Error when device not connected */ +"chats.error.noDeviceConnected" = "Nenhum dispositivo ligado"; + +/* Location: ChannelInfoSheet.swift - Error when services unavailable */ +"chats.error.servicesUnavailable" = "Serviços indisponíveis"; + +/* Location: ConversationActionError.swift - Error when the radio is disconnected and a conversation delete is attempted */ +"chats.error.notConnectedToDelete" = "Ligue o rádio para eliminar esta conversa."; + +/* Location: ChatConversationView.swift - Error when loading older messages fails */ +"chats.error.loadOlderMessagesFailed" = "Falha ao carregar mensagens anteriores"; + +/* Location: ErrorBannerModifier.swift - Accessibility hint announcing that tapping the banner dismisses it */ +"chats.error.banner.dismissAccessibilityHint" = "Toque para fechar"; + +/* Location: ChatViewModel - Error when persisting a queued send fails (SwiftData write error) */ +"chats.error.sendQueuePersistFailed" = "Não foi possível colocar a mensagem na fila. Tente novamente."; + +/* Location: ChatViewModel+Messages.swift - Error banner when fetching the conversation list fails */ +"chats.error.loadConversationsFailed" = "Não foi possível carregar as conversas."; + +// MARK: - Channel Options Sheet + +/* Location: ChannelOptionsSheet.swift - Loading indicator text */ +"chats.channelOptions.loading" = "A carregar canais..."; + +/* Location: ChannelOptionsSheet.swift - Navigation title */ +"chats.channelOptions.title" = "Novo canal"; + +/* Location: ChannelOptionsSheet.swift - Create private channel option title */ +"chats.channelOptions.createPrivate.title" = "Criar um canal privado"; + +/* Location: ChannelOptionsSheet.swift - Create private channel option description */ +"chats.channelOptions.createPrivate.description" = "Gerar uma chave secreta e um código QR para partilhar"; + +/* Location: ChannelOptionsSheet.swift - Join private channel option title */ +"chats.channelOptions.joinPrivate.title" = "Aderir a um canal privado"; + +/* Location: ChannelOptionsSheet.swift - Join private channel option description */ +"chats.channelOptions.joinPrivate.description" = "Introduza o nome do canal e a chave secreta"; + +/* Location: ChannelOptionsSheet.swift - Scan QR code option title */ +"chats.channelOptions.scanQR.title" = "Digitalizar um código QR"; + +/* Location: ChannelOptionsSheet.swift - Scan QR code option description */ +"chats.channelOptions.scanQR.description" = "Aderir a um canal ao digitalizar o código QR"; + +/* Location: ChannelOptionsSheet.swift - Section header for private channels */ +"chats.channelOptions.section.private" = "Canais privados"; + +/* Location: ChannelOptionsSheet.swift - Join public channel option title */ +"chats.channelOptions.joinPublic.title" = "Aderir ao canal público"; + +/* Location: ChannelOptionsSheet.swift - Join public channel option description */ +"chats.channelOptions.joinPublic.description" = "O canal público predefinido"; + +/* Location: ChannelOptionsSheet.swift - Join hashtag channel option title */ +"chats.channelOptions.joinHashtag.title" = "Aderir a um canal hashtag"; + +/* Location: ChannelOptionsSheet.swift - Join hashtag channel option description */ +"chats.channelOptions.joinHashtag.description" = "Canal público ao qual qualquer pessoa pode aderir pelo nome"; + +/* Location: ChannelOptionsSheet.swift - Section header for public channels */ +"chats.channelOptions.section.public" = "Canais públicos"; + +/* Location: ChannelOptionsSheet.swift - Footer when all slots are in use */ +"chats.channelOptions.footer.noSlots" = "Todos os slots de canal estão em utilização. Elimine um canal existente para adicionar um novo."; + +/* Location: ChannelOptionsSheet.swift - Footer when public channel already exists */ +"chats.channelOptions.footer.hasPublic" = "O canal público já está configurado no slot 0."; + +// MARK: - New Chat View + +/* Location: NewChatView.swift - Empty state title */ +"chats.newChat.emptyState.title" = "Nenhum contacto"; + +/* Location: NewChatView.swift - Empty state description */ +"chats.newChat.emptyState.description" = "Os contactos aparecem quando forem descobertos"; + +/* Location: NewChatView.swift - Navigation title */ +"chats.newChat.title" = "Novo chat"; + +/* Location: NewChatView.swift - Search placeholder */ +"chats.newChat.search.placeholder" = "Pesquisar contactos"; + +/* Location: NewChatView.swift - Contact type label for direct contacts */ +"chats.newChat.contactType.direct" = "Direto"; + +/* Location: NewChatView.swift - Contact type label for repeaters */ +"chats.newChat.contactType.repeater" = "Repetidor"; + +/* Location: NewChatView.swift - Contact type label for rooms */ +// MARK: - Conversation Row + +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for favorite indicator */ +"chats.row.favorite" = "Favorito"; + +/* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ +"chats.row.noMessages" = "Ainda sem mensagens"; + +/* Location: MutedIndicator.swift - Accessibility label for muted indicator */ +"chats.row.muted" = "Silenciado"; + +/* Location: MutedIndicator.swift - Accessibility label for mentions-only indicator */ +"chats.row.mentionsOnly" = "Só menções"; + +// MARK: - Notification Level Picker + +/* Location: NotificationLevelPicker.swift - Accessibility label for notification level picker */ +"chats.notificationLevel.label" = "Nível de notificação"; + +/* Location: NotificationLevelPicker.swift - Accessibility hint for notification level picker */ +"chats.notificationLevel.hint" = "Escolha quando receber notificações"; + +/* Location: NotificationLevelPicker.swift - Muted level label */ +"chats.notificationLevel.muted" = "Silenciado"; + +/* Location: NotificationLevelPicker.swift - Mentions-only level label */ +"chats.notificationLevel.mentions" = "Menções"; + +/* Location: NotificationLevelPicker.swift - All notifications level label */ +"chats.notificationLevel.all" = "Todas"; + +/* Location: NotificationLevelPicker.swift - Accessibility description for muted level */ +"chats.notificationLevel.accessibility.muted" = "Silenciado, sem notificações"; + +/* Location: NotificationLevelPicker.swift - Accessibility description for mentions-only level */ +"chats.notificationLevel.accessibility.mentionsOnly" = "Só menções"; + +/* Location: NotificationLevelPicker.swift - Accessibility description for all-notifications level */ +"chats.notificationLevel.accessibility.all" = "Todas as notificações"; + +// MARK: - Swipe Actions + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to delete */ +"chats.action.delete" = "Eliminar"; + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to unmute */ +"chats.action.unmute" = "Reativar som"; + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to mute */ +"chats.action.mute" = "Silenciar"; + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to remove from favorites */ +"chats.action.unfavorite" = "Remover dos favoritos"; + +/* Location: ConversationContextMenuModifier.swift - Context-menu action to add to favorites */ +"chats.action.favorite" = "Favorito"; + +// MARK: - Room Conversation Row + +/* Location: RoomConversationRow.swift - Status when room is connected */ +"chats.room.connected" = "Ligado"; + +/* Location: RoomConversationRow.swift - Prompt to reconnect to room */ +"chats.room.tapToReconnect" = "Toque para voltar a ligar"; + +// MARK: - Room Authentication Sheet + +/* Location: RoomAuthenticationSheet.swift - Error title when room not found */ +"chats.roomAuth.notFound.title" = "Sala não encontrada"; + +/* Location: RoomAuthenticationSheet.swift - Error description when room not found */ +"chats.roomAuth.notFound.description" = "Não foi possível encontrar o contacto da sala"; + +// MARK: - Conversation List Sections + +/* Location: ConversationListContent.swift - Section accessibility label for favorites */ +/* Location: ConversationListContent.swift - Section accessibility label for other conversations */ +// MARK: - Mention Suggestions + +/* Location: MentionSuggestionRow.swift - Accessibility label for mention row - %@ is contact name */ +"chats.mention.accessibility.label" = "Mencionar %@"; + +/* Location: MentionSuggestionRow.swift - Accessibility hint for channel sender mention */ +"chats.mention.accessibility.hintChannel" = "Remetente do canal. Toque duas vezes para mencionar"; + +/* Location: MentionSuggestionRow.swift - Accessibility hint for saved contact mention */ +"chats.mention.accessibility.hintContact" = "Contacto guardado. Toque duas vezes para mencionar"; + +/* Location: MentionSuggestionView.swift - Accessibility label for mention suggestions popup */ +"chats.suggestions.accessibilityLabel" = "Sugestões de menção"; + +// MARK: - Create Private Channel View + +/* Location: CreatePrivateChannelView.swift - Title when sharing created channel */ +"chats.createPrivate.titleShare" = "Partilhar canal privado"; + +/* Location: CreatePrivateChannelView.swift - Title when creating channel */ +"chats.createPrivate.titleCreate" = "Criar canal privado"; + +/* Location: CreatePrivateChannelView.swift - Text field placeholder for channel name */ +"chats.createPrivate.channelName" = "Nome do canal"; + +/* Location: CreatePrivateChannelView.swift - Section header for channel details */ +"chats.createPrivate.section.details" = "Detalhes do canal"; + +/* Location: CreatePrivateChannelView.swift - Section header for generated secret */ +"chats.createPrivate.section.secret" = "Segredo gerado"; + +/* Location: CreatePrivateChannelView.swift - Footer explaining generated secret */ +"chats.createPrivate.secretFooter" = "Foi gerada uma chave secreta aleatória. Poderá partilhá-la através de um código QR depois de criar o canal."; + +/* Location: CreatePrivateChannelView.swift - Button to create channel */ +"chats.createPrivate.createButton" = "Criar canal"; + +/* Location: CreatePrivateChannelView.swift - Section header for manual sharing */ +"chats.createPrivate.section.shareManually" = "Partilhar manualmente"; + +/* Location: CreatePrivateChannelView.swift - Footer explaining manual sharing */ +"chats.createPrivate.shareManuallyFooter" = "Partilhe o nome do canal e esta chave secreta com outras pessoas. São ambos necessários para aderir."; + +// MARK: - Join Private Channel View + +/* Location: JoinPrivateChannelView.swift - Text field placeholder for secret key */ +"chats.joinPrivate.secretKeyPlaceholder" = "Chave secreta (32 caracteres hexadecimais)"; + +/* Location: JoinPrivateChannelView.swift - Validation error for invalid secret */ +"chats.joinPrivate.error.invalidSecret" = "A chave secreta deve ter exatamente 32 caracteres hexadecimais (0-9, A-F)"; + +/* Location: JoinPrivateChannelView.swift - Footer explaining how to join */ +"chats.joinPrivate.footer" = "Introduza o nome do canal e a chave secreta partilhados por quem criou o canal."; + +/* Location: JoinPrivateChannelView.swift - Button to join channel */ +"chats.joinPrivate.joinButton" = "Aderir ao canal"; + +/* Location: JoinPrivateChannelView.swift - Navigation title */ +"chats.joinPrivate.title" = "Aderir a canal privado"; + +/* Location: JoinPrivateChannelView.swift - Error for invalid secret key format */ +"chats.joinPrivate.error.invalidFormat" = "Formato de chave secreta inválido"; + +// MARK: - Join Public Channel View + +/* Location: JoinPublicChannelView.swift - Channel name displayed */ +"chats.joinPublic.channelName" = "Canal público"; + +/* Location: JoinPublicChannelView.swift - Description of public channel */ +"chats.joinPublic.description" = "O canal público é um canal aberto de transmissão no slot 0. Todos os dispositivos na rede mesh podem enviar e receber mensagens neste canal."; + +/* Location: JoinPublicChannelView.swift - Button to add public channel */ +"chats.joinPublic.addButton" = "Adicionar canal público"; + +/* Location: JoinPublicChannelView.swift - Navigation title */ +"chats.joinPublic.title" = "Aderir ao canal público"; + +// MARK: - Join Hashtag Channel View + +/* Location: JoinHashtagChannelView.swift - Text field placeholder */ +"chats.joinHashtag.placeholder" = "nome-do-canal"; + +/* Location: JoinHashtagChannelView.swift - Section header */ +"chats.joinHashtag.section.header" = "Canal hashtag"; + +/* Location: JoinHashtagChannelView.swift - Footer explaining hashtag channels */ +"chats.joinHashtag.footer" = "Os canais hashtag são públicos. Qualquer pessoa pode aderir ao introduzir o mesmo nome. Só são permitidas letras minúsculas, números e hífenes."; + +/* Location: JoinHashtagChannelView.swift - Description about encryption */ +"chats.joinHashtag.encryptionDescription" = "O nome do canal é usado para gerar a chave de encriptação. Qualquer pessoa com o mesmo nome pode ler as mensagens."; + +/* Location: JoinHashtagChannelView.swift - Button format for existing channel - %@ is channel name */ +"chats.joinHashtag.goToButton" = "Ir para #%@"; + +/* Location: JoinHashtagChannelView.swift - Button format for new channel - %@ is channel name */ +"chats.joinHashtag.joinButton" = "Aderir a #%@"; + +/* Location: JoinHashtagChannelView.swift - Accessibility hint for existing channel */ +"chats.joinHashtag.existingHint" = "Abre o canal ao qual já aderiu"; + +/* Location: JoinHashtagChannelView.swift - Accessibility hint for new channel */ +"chats.joinHashtag.newHint" = "Cria e adere a este canal hashtag"; + +/* Location: JoinHashtagChannelView.swift - Footer label when channel already joined */ +"chats.joinHashtag.alreadyJoined" = "Já aderiu"; + +/* Location: JoinHashtagChannelView.swift - Accessibility label for already joined */ +"chats.joinHashtag.alreadyJoinedAccessibility" = "Canal já aderido"; + +/* Location: JoinHashtagChannelView.swift - Navigation title */ +"chats.joinHashtag.title" = "Aderir a canal hashtag"; + +// MARK: - Join Hashtag From Message View + +/* Location: JoinHashtagFromMessageView.swift - Loading text */ +"chats.joinFromMessage.loading" = "A carregar..."; + +/* Location: JoinHashtagFromMessageView.swift - Navigation title */ +"chats.joinFromMessage.title" = "Aderir ao canal"; + +/* Location: JoinHashtagFromMessageView.swift - No device connected title */ +"chats.joinFromMessage.noDevice.title" = "Nenhum dispositivo ligado"; + +/* Location: JoinHashtagFromMessageView.swift - No device connected description - %@ is channel name */ +"chats.joinFromMessage.noDevice.description" = "Ligue um dispositivo para aderir a %@."; + +/* Location: JoinHashtagFromMessageView.swift - No slots available title */ +"chats.joinFromMessage.noSlots.title" = "Nenhum slot disponível"; + +/* Location: JoinHashtagFromMessageView.swift - No slots available description - %@ is channel name */ +"chats.joinFromMessage.noSlots.description" = "Todos os slots de canal estão cheios. Remova um canal existente para aderir a %@."; + +/* Location: JoinHashtagFromMessageView.swift - Description of hashtag channels */ +"chats.joinFromMessage.description" = "Os canais hashtag são públicos. Qualquer pessoa pode aderir ao introduzir o mesmo nome."; + +/* Location: JoinHashtagFromMessageView.swift - Button to join channel - %@ is channel name */ +"chats.joinFromMessage.joinButton" = "Aderir a %@"; + +/* Location: JoinHashtagFromMessageView.swift - Error for no available slots */ +"chats.joinFromMessage.error.noSlots" = "Nenhum slot disponível."; + +/* Location: JoinHashtagFromMessageView.swift - Error for invalid channel name */ +"chats.joinFromMessage.error.invalidName" = "Formato de nome de canal inválido."; + +/* Location: JoinHashtagFromMessageView.swift - Error when channel created but couldn't be loaded */ +"chats.joinFromMessage.error.loadFailed" = "Canal criado, mas não foi possível carregá-lo."; + +/* Location: JoinChannelConfirmationSheet.swift - Region scope shown before join - %@ is region name */ +"chats.joinFromMessage.regionScope" = "Região: %@"; + +/* Location: JoinChannelConfirmationSheet.swift - Soft warning when hashtag-shaped name uses a non-public secret */ +"chats.joinFromMessage.hashtagSecretMismatch" = "Este segredo não corresponde à chave do hashtag público. Irá aderir a um canal privado com este nome."; + +// MARK: - Scan Channel QR View + +/* Location: ScanChannelQRView.swift - Navigation title */ +"chats.scanQR.title" = "Digitalizar código QR"; + +/* Location: ScanChannelQRView.swift - Error when scanner not available */ +"chats.scanQR.notAvailable.title" = "Scanner indisponível"; + +/* Location: ScanChannelQRView.swift - Error description when scanner not available */ +"chats.scanQR.notAvailable.description" = "A digitalização de QR não é suportada neste dispositivo"; + +/* Location: ScanChannelQRView.swift - Instruction to point camera */ +"chats.scanQR.instruction" = "Aponte a câmara para o código QR de um canal"; + +/* Location: ScanChannelQRView.swift - Button to scan again */ +"chats.scanQR.scanAgain" = "Digitalizar novamente"; + +/* Location: ScanChannelQRView.swift - Camera permission denied title */ +"chats.scanQR.permissionDenied.title" = "É necessário acesso à câmara"; + +/* Location: ScanChannelQRView.swift - Camera permission denied message */ +"chats.scanQR.permissionDenied.message" = "Ative o acesso à câmara em Definições para digitalizar códigos QR."; + +/* Location: ScanChannelQRView.swift - Button to open settings */ +"chats.scanQR.openSettings" = "Abrir Definições"; + +/* Location: ScanChannelQRView.swift - Error for invalid QR format */ +"chats.scanQR.error.invalidFormat" = "Formato de código QR inválido"; + +/* Location: ScanChannelQRView.swift - Error for invalid channel data */ +// MARK: - Message Bubble + +/* Location: UnifiedMessageBubble.swift - Fallback sender name */ +"chats.message.sender.unknown" = "Desconhecido"; + +/* Location: UnifiedMessageBubble.swift - Accessibility text for sender names resolved from a short prefix */ +"chats.message.sender.possibleMatch" = "Possível correspondência, identificada pelo prefixo curto"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to reply */ +"chats.message.action.reply" = "Responder"; + +/* Location: MessageActionsSheet.swift - Context menu action to mention */ +"chats.message.action.mention" = "Mencionar"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to copy */ +"chats.message.action.copy" = "Copiar"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ +"chats.message.action.repeatDetails" = "Detalhes da repetição"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to send again */ +"chats.message.action.sendAgain" = "Enviar novamente"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing heard repeats - %d is count, second %@ is "repeat" or "repeats" */ +"chats.message.info.heardRepeats" = "Receção: %d %@"; + +/* Location: UnifiedMessageBubble.swift - Singular form of repeat */ +"chats.message.repeat.singular" = "repetição"; + +/* Location: UnifiedMessageBubble.swift - Plural form of repeats */ +"chats.message.repeat.plural" = "repetições"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing sent time - %@ is formatted date */ +"chats.message.info.sent" = "Enviado: %@"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing round trip time - %d is milliseconds */ +"chats.message.info.roundTrip" = "Ida e volta: %dms"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to view path */ +"chats.message.action.viewPath" = "Ver caminho"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing hop count - %@ is count or "Direct" */ +"chats.message.info.hops" = "Saltos: %@"; + +/* Location: ActionsDetailsSection.swift - Info row showing the path hash size in bytes - %d is 1, 2, or 3 */ +"chats.message.info.pathHash" = "Hash do caminho: %d byte"; + +/* Location: UnifiedMessageBubble.swift - Indicator that timestamp was adjusted */ +"chats.message.info.adjusted" = "(ajustado)"; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for adjusted timestamp */ +/* Location: UnifiedMessageBubble.swift - Accessibility hint for adjusted timestamp */ +/* Location: ActionsDetailsSection.swift - Info row showing the raw uncorrected wire send time when the timestamp was corrected - %@ is formatted date */ +"chats.message.info.originalSendTime" = "Hora de envio original: %@"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing received time - %@ is formatted date */ +"chats.message.info.received" = "Recebido: %@"; + +/* Location: UnifiedMessageBubble.swift - Context menu text showing SNR - %@ is formatted value */ +"chats.message.info.snr" = "SNR: %@"; + +/* Location: MessageActionsSheet.swift - Details row showing the radio region a flood-routed message was broadcast under - %@ is region name */ +"chats.message.info.floodedUnder" = "Região: %@"; + +/* Location: MessageActionsSheet.swift - Details row shown when region could not be resolved */ +"chats.message.info.regionUnresolved" = "Região: Desconhecida"; + +/* Location: ActionsDetailsSection.swift - Details row when multiple known regions match the packet transport code - %@ is localized list of names */ +"chats.message.info.regionAmbiguous" = "Região (ambígua): %@"; + +/* Location: UnifiedMessageBubble.swift - Context menu submenu label */ +"chats.message.action.details" = "Detalhes"; + +/* Location: ActionsDetailsSection.swift - Accessibility value when the details row is expanded */ +"chats.message.action.expanded" = "Expandido"; + +/* Location: ActionsDetailsSection.swift - Accessibility value when the details row is collapsed */ +"chats.message.action.collapsed" = "Recolhido"; + +/* Location: UnifiedMessageBubble.swift - Context menu action to delete */ +"chats.message.action.delete" = "Eliminar"; + +/* Location: MessageActionsSheet.swift - Purpose: Block sender action */ +"chats.message.action.blockSender" = "Bloquear remetente"; + +/* Location: MessageActionsSheet.swift - Purpose: Action to start a DM with the channel sender */ +"chats.message.action.sendDM" = "Enviar DM"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to retry a failed send */ +"chats.message.action.retry" = "Tentar novamente"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to open the reactions detail sheet */ +"chats.message.action.viewReactions" = "Ver reações"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to open the message's link preview URL */ +"chats.message.action.openLink" = "Abrir ligação"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a web link, %@ is the host */ +"chats.message.action.openWebLink" = "Abrir ligação: %@"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a shared map coordinate */ +"chats.message.action.openMapLink" = "Abrir mapa"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a mention, %@ is the mentioned name */ +"chats.message.action.openMention" = "Menção: %@"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a hashtag channel, %@ is the channel name */ +"chats.message.action.openHashtag" = "Abrir #%@"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to add a shared contact, %@ is the contact name */ +"chats.message.action.addContact" = "Adicionar contacto: %@"; + +/* Location: MessageLinkAccessibility.swift - VoiceOver action to open a shared channel link, %@ is the channel name */ +"chats.message.action.openChannel" = "Abrir canal: %@"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to open an attached image full-screen */ +"chats.message.action.viewImage" = "Ver imagem"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver action to retry a failed image download */ +"chats.message.action.retryImage" = "Tentar imagem novamente"; + +/* Location: UnifiedMessageBubble.swift - Status row retry button */ +"chats.message.status.retry" = "Tentar novamente"; + +/* Location: UnifiedMessageBubble.swift - Message status sending */ +"chats.message.status.sending" = "A enviar..."; + +/* Location: UnifiedMessageBubble.swift - Message status sent */ +"chats.message.status.sent" = "Enviado"; + +/* Location: UnifiedMessageBubble.swift - Message status sent multiple times - %d is count */ +"chats.message.status.sentMultiple" = "Enviado %d vezes"; + +/* Location: UnifiedMessageBubble.swift - Message status delivered */ +"chats.message.status.delivered" = "Entregue"; + +/* Location: UnifiedMessageBubble.swift - Message status failed */ +"chats.message.status.failed" = "Falhou"; + +/* Location: UnifiedMessageBubble.swift - Message status retrying */ +"chats.message.status.retrying" = "A tentar novamente..."; + +/* Location: UnifiedMessageBubble.swift - Message status retrying with attempt count - %d is current attempt, %d is max attempts */ +"chats.message.status.retryingAttempt" = "A tentar %d/%d"; + +/* Location: UnifiedMessageBubble.swift - SNR quality excellent */ +"chats.signal.excellent" = "Excelente"; + +/* Location: UnifiedMessageBubble.swift - SNR quality good */ +"chats.signal.good" = "Bom"; + +/* Location: UnifiedMessageBubble.swift - SNR quality fair */ +"chats.signal.fair" = "Razoável"; + +/* Location: UnifiedMessageBubble.swift - SNR quality poor */ +"chats.signal.poor" = "Fraco"; + +/* Location: UnifiedMessageBubble.swift - SNR quality very poor */ +/* Location: UnifiedMessageBubble.swift - Hop count direct */ +"chats.message.hops.direct" = "Direto"; + +/* Location: UnifiedMessageBubble.swift - Path footer for direct messages (no hops) */ +"chats.message.path.direct" = "Direto"; +/* Routing strategy where the radio rebroadcasts the message to every neighbor (no specific path). UI displays this as a label in the per-message details sheet. Prefer a localized term over the English loanword if one exists in your locale's networking terminology. */ +"chats.message.path.flood" = "Flood"; + +/* Location: UnifiedMessageBubble.swift - Fallback path showing hop count - %d is number */ +/* Location: MessagePathFormatter.swift - Fallback when path nodes unavailable */ +/* Location: UnifiedMessageBubble.swift - Accessibility label for routing path - %@ is the path */ +"chats.message.path.accessibilityLabel" = "Caminho de encaminhamento: %@"; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for hop count display - %d is count */ +"chats.message.hopCount.accessibilityLabel" = "Número de saltos: %d"; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for region footer - %@ is region name */ +"chats.message.region.accessibilityLabel" = "Região %@"; + +/* Location: BubbleFooterRow.swift - VoiceOver when multiple regions match - %@ is localized list of names */ +"chats.message.region.ambiguousAccessibilityLabel" = "Região, ambígua: %@"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility label for region multi-match */ +"chats.message.region.ambiguous.possibleMatch" = "Várias regiões correspondentes"; + +/* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ +"chats.message.region.ambiguous.possibleMatchHint" = "Mais de uma região da lista corresponde ao código de transporte deste pacote. A aplicação não consegue identificar qual foi usada."; + +/* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ +"chats.message.region.ambiguous.popoverTitle" = "Várias regiões correspondentes"; + +/* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ +"chats.message.region.ambiguous.popoverBody" = "Mais de uma região da lista corresponde ao código de transporte deste pacote. A aplicação não consegue identificar qual foi usada.%@"; + +/* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ +"chats.message.sendTime.accessibilityLabel" = "Hora de envio: %@"; + +/* Location: BubbleFooterRow.swift - Accessibility label for the send time footer when the sender's clock was corrected - %@ is the time */ +"chats.message.sendTime.correctedAccessibilityLabel" = "Hora de envio: %@, ajustada devido a um erro no relógio do remetente"; + +// MARK: - Reactions + +/* Location: ReactionBadgesView.swift - Accessibility label for reaction badge - %@ is emoji, %d is count */ +"reactions.badge" = "%@ %d"; + +/* Location: ReactionBadgesView.swift - Accessibility hint for reaction badge */ +"reactions.badge_hint" = "Toque duas vezes para adicionar uma reação, toque sem soltar para ver detalhes"; + +/* Location: ReactionBadgesView.swift - Accessibility label for overflow badge - %d is count */ +"reactions.more_badge" = "%d tipos de reação adicionais"; + +/* Location: ReactionBadgesView.swift - Accessibility hint for overflow badge */ +"reactions.more_badge_hint" = "Toque duas vezes para ver todas as reações"; + +/* Location: EmojiPickerRow.swift - Label for more emojis button */ +"reactions.more_emojis" = "Mais emojis"; + +/* Location: ReactionBadgesView.swift - VoiceOver accessibility action to view reaction details */ +"reactions.view_details" = "Ver detalhes das reações"; + +/* Location: ReactionDetailsSheet.swift - Navigation title */ +/* Location: ChatViewModel.swift - Error message when reaction fails to send */ +/* Location: MessageContextOverlay.swift - Reply action label */ +/* Location: MessageContextOverlay.swift - Copy action label */ +/* Location: MessageContextOverlay.swift - Delete action label */ +// MARK: - Message Actions Sheet + +/* Location: MessageActionsSheet.swift - Sheet title */ +// MARK: - Chat Input Bar + +/* Location: ChatInputBar.swift - Accessibility label for text input */ +"chats.input.accessibilityLabel" = "Campo de mensagem"; + +/* Location: ChatInputBar.swift - Accessibility hint for text input */ +"chats.input.accessibilityHint" = "Introduza a mensagem aqui"; + +/* Location: ChatInputBar.swift - Accessibility label for character count - %d is current, %d is max */ +"chats.input.characterCount" = "%d de %d caracteres"; + +/* Location: ChatInputBar.swift - Accessibility label when message too long */ +"chats.input.tooLong" = "Mensagem demasiado longa"; + +/* Location: ChatInputBar.swift - Accessibility label for send button */ +"chats.input.sendMessage" = "Enviar mensagem"; + +/* Location: ChatInputBar.swift - Accessibility hint when over character limit - %d is characters to remove */ +"chats.input.removeCharacters" = "Remova %d caracteres para enviar"; + +/* Location: ChatInputBar.swift - Accessibility hint when not connected */ +"chats.input.requiresConnection" = "Requer ligação ao rádio"; + +/* Location: ChatInputBar.swift - Accessibility hint when ready to send */ +"chats.input.tapToSend" = "Toque para enviar a mensagem"; + +/* Location: ChatInputBar.swift - Accessibility hint when message is empty */ +"chats.input.typeFirst" = "Introduza primeiro uma mensagem"; + +/* Location: ChatInputBar.swift - Accessibility label for encrypted indicator */ +"chats.input.encrypted" = "Encriptado"; + +/* Location: ChatInputBar.swift - Accessibility label for not encrypted indicator */ +"chats.input.notEncrypted" = "Não encriptado"; + +/* Location: ChatShareMenu.swift - Accessibility label for the share (plus) button */ +"chats.input.shareButton.accessibilityLabel" = "Partilhar"; + +// MARK: - Share Menu + +/* Location: ChatShareMenu.swift - Share menu action to share the current location */ +"chats.share.location" = "Partilhar localização"; + +/* Location: ChatShareMenu.swift - Share menu action to share a contact */ +"chats.share.contact" = "Partilhar contacto"; + +/* Location: ChatShareMenu.swift - Share menu action to share the user's own node info */ +"chats.share.myInfo" = "Partilhar as minhas informações"; + +// MARK: - Contact Picker + +/* Location: ShareContactPickerSheet.swift - Navigation title for the share-contact picker */ +"chats.contactPicker.title" = "Partilhar contacto"; + +/* Location: ShareContactPickerSheet.swift - Search placeholder for the contact picker */ +"chats.contactPicker.search.placeholder" = "Pesquisar contactos"; + +/* Location: ShareContactPickerSheet.swift - Empty state when no contacts are available to share */ +"chats.contactPicker.emptyState" = "Nenhum contacto para partilhar"; + +// MARK: - Message Path Sheet + +/* Location: MessagePathSheet.swift - Empty state title */ +"chats.path.unavailable.title" = "Caminho indisponível"; + +/* Location: MessagePathSheet.swift - Empty state description */ +"chats.path.unavailable.description" = "Os dados do caminho não estão disponíveis para esta mensagem"; + +/* Location: MessagePathSheet.swift - Button to copy path */ +"chats.path.copyButton" = "Copiar caminho"; + +/* Location: MessagePathSheet.swift - Accessibility label for copy button */ +"chats.path.copyAccessibility" = "Copiar caminho para a área de transferência"; + +/* Location: MessagePathSheet.swift - Accessibility hint for copy button */ +"chats.path.copyHint" = "Copia os IDs dos nodos como valores hexadecimais"; + +/* Location: MessagePathSheet.swift - Section header for path */ +/* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ +"chats.path.map" = "Mapa do caminho"; + +/* Location: MessagePathMapView.swift - Center on path map control accessibility label */ +"chats.path.centerOnPath" = "Centrar no caminho"; + +// MARK: - Path Hop Row View + +/* Location: PathHopRowView.swift - Unknown node name */ +"chats.path.hop.unknown" = ""; + +/* Location: PathHopRowView.swift - Accessibility text for path hop names resolved from a short prefix */ +"chats.path.hop.possibleMatch" = "Possível correspondência, identificada pelo prefixo curto"; + +/* Location: FallbackMatchIndicatorView.swift - Title for possible match explanation popover */ +"chats.path.hop.possibleMatchTitle" = "Possível correspondência"; + +/* Location: FallbackMatchIndicatorView.swift - Explanation of what a possible match means */ +"chats.path.hop.possibleMatchExplanation" = "Vários nodos partilham este prefixo. O nome apresentado pode não estar correto."; + +/* Location: PathHopRowView.swift - Label for sender (first hop) */ +"chats.path.hop.sender" = "Remetente"; + +/* Location: PathHopRowView.swift - Label for intermediate hops - %d is hop number */ +"chats.path.hop.number" = "Salto %d"; + +/* Location: PathHopRowView.swift - Accessibility value format for last hop - %@ is quality, %@ is SNR */ +"chats.path.hop.signalQuality" = "Qualidade do sinal: %@, SNR %@ dB"; + +/* Location: PathHopRowView.swift - Accessibility value format for non-last hops - %@ is hex ID */ +"chats.path.hop.nodeId" = "ID do nodo: %@"; + +/* Location: PathHopRowView.swift - Unknown signal quality */ +"chats.path.hop.signalUnknown" = "Desconhecida"; + +/* Location: PathHopRowView.swift - Label for receiver (your device) */ +"chats.path.receiver.label" = "Destinatário"; + +/* Location: MessagePathSheet.swift - Fallback name when device name unavailable */ +"chats.path.receiver.you" = "Eu"; + +// MARK: - Repeat Details Sheet + +/* Location: RepeatDetailsSheet.swift - Empty state title */ +"chats.repeats.emptyState.title" = "Ainda sem repetições"; + +/* Location: RepeatDetailsSheet.swift - Empty state description */ +"chats.repeats.emptyState.description" = "As repetições aparecem aqui à medida que a mensagem se propaga pela mesh"; + +/* Location: RepeatDetailsSheet.swift - Navigation title */ +// MARK: - Repeat Row View + +/* Location: RepeatRowView.swift - Accessibility label format - %@ is repeater name */ +"chats.repeats.row.accessibility" = "Repetição de %@"; + +/* Location: RepeatRowView.swift - Accessibility value format - %@ is quality, %@ is SNR, %@ is RSSI */ +"chats.repeats.row.accessibilityValue" = "Sinal %@, SNR %@, RSSI %@"; + +/* Location: RepeatRowView.swift - Singular hop label */ +"chats.repeats.hop.singular" = "1 salto"; + +/* Location: RepeatRowView.swift - Plural hops label - %d is count */ +"chats.repeats.hop.plural" = "%d saltos"; + +/* Location: RepeatRowView.swift - Unknown repeater name */ +"chats.repeats.unknownRepeater" = ""; + +// MARK: - Link Preview Card + +/* Location: LinkPreviewCard.swift - Accessibility label for link preview - %1$@ is title, %2$@ is domain */ +"chats.linkPreview.accessibility.label" = "%1$@, de %2$@, ligação"; + +/* Location: LinkPreviewCard.swift - Accessibility hint for link preview */ +"chats.linkPreview.accessibility.hint" = "Abre no navegador"; + +// MARK: - Tap to Load Preview + +/* Location: TapToLoadPreview.swift - Loading state text */ +"chats.preview.loading" = "A carregar pré-visualização..."; + +/* Location: TapToLoadPreview.swift - Idle state text */ +"chats.preview.tapToLoad" = "Toque para carregar a pré-visualização"; + +/* Location: TapToLoadPreview.swift - Loading accessibility label format - %@ is host */ +"chats.preview.loadingAccessibility" = "A carregar pré-visualização de %@"; + +/* Location: TapToLoadPreview.swift - Idle accessibility label format - %@ is host */ +"chats.preview.tapAccessibility" = "Carregar pré-visualização de %@"; + +/* Location: TapToLoadPreview.swift - Loading accessibility hint */ +"chats.preview.loadingHint" = "Aguarde"; + +/* Location: TapToLoadPreview.swift - Idle accessibility hint */ +"chats.preview.tapHint" = "Obtém o título e a imagem do sítio web"; + +// MARK: - Inline Image + +/* Location: InlineImageView.swift - Accessibility label for static image */ +"chats.inlineImage.imageAccessibility" = "Imagem"; + +/* Location: InlineImageFragmentView.swift - Accessibility label for loading state */ +/* Location: InlineImageView.swift - Accessibility label for animated image */ +"chats.inlineImage.animatedAccessibility" = "Imagem animada"; + +/* Location: InlineImageView.swift - Accessibility hint for tap to view full screen */ +"chats.inlineImage.tapHint" = "Toque duas vezes para ver em ecrã inteiro"; + +/* Location: UnifiedMessageBubble.swift - Tap to retry loading failed image */ +"chats.inlineImage.tapToRetry" = "Toque para tentar novamente"; + +/* Location: UnifiedMessageBubble.swift - Accessibility hint for retry button */ +"chats.inlineImage.retryHint" = "Toque duas vezes para tentar carregar a imagem novamente"; + +/* Location: InlineImageFragmentView.swift - VoiceOver identity for a failed inline image */ +"chats.inlineImage.failedLabel" = "Falha ao carregar a imagem"; + +// MARK: - Image Viewer + +/* Location: FullScreenImageViewer.swift - Close button label */ +"chats.imageViewer.close" = "Fechar"; + +/* Location: FullScreenImageViewer.swift - Share button label */ +"chats.imageViewer.share" = "Imagem"; + +// MARK: - Common Buttons (using Localizable.strings common keys where possible) + +/* Location: Various - OK button (use L10n.Localizable.Common.ok) */ +"chats.common.ok" = "OK"; + +/* Location: Various - Cancel button (use L10n.Localizable.Common.cancel) */ +"chats.common.cancel" = "Cancelar"; + +/* Location: Various - Done button (use L10n.Localizable.Common.done) */ +"chats.common.done" = "OK"; + +// MARK: - Reaction Details Sheet + +/* Location: ReactionDetailsSheet.swift - Navigation title */ +"reactions.title" = "Reações"; + +/* Location: ReactionDetailsSheet.swift - Empty state title */ +"reactions.emptyState.title" = "Nenhuma reação"; + +/* Location: ReactionDetailsSheet.swift - Empty state description */ +"reactions.emptyState.description" = "Ainda ninguém reagiu a esta mensagem"; + +// MARK: - Emoji Picker Categories + +/* Location: EmojiProvider.swift - Frequently used category */ +"reactions.emoji.category.frequent" = "Utilizados com frequência"; + +/* Location: EmojiProvider.swift - People category */ +"reactions.emoji.category.people" = "Smileys e pessoas"; + +/* Location: EmojiProvider.swift - Nature category */ +"reactions.emoji.category.nature" = "Animais e natureza"; + +/* Location: EmojiProvider.swift - Foods category */ +"reactions.emoji.category.foods" = "Comida e bebida"; + +/* Location: EmojiProvider.swift - Activity category */ +"reactions.emoji.category.activity" = "Atividade"; + +/* Location: EmojiProvider.swift - Places category */ +"reactions.emoji.category.places" = "Viagens e locais"; + +/* Location: EmojiProvider.swift - Objects category */ +"reactions.emoji.category.objects" = "Objetos"; + +/* Location: EmojiProvider.swift - Symbols category */ +"reactions.emoji.category.symbols" = "Símbolos"; + +/* Location: EmojiProvider.swift - Flags category */ +"reactions.emoji.category.flags" = "Bandeiras"; + +/* Location: EmojiPickerSheet.swift - Search placeholder */ +"reactions.emoji.searchPlaceholder" = "Pesquisar emojis"; + +// MARK: - Divider + +/* Location: NewMessagesDividerView.swift - Label for new messages divider */ +"chats.divider.newMessages" = "Novas mensagens"; + +/* Location: NewMessagesDividerView.swift - VoiceOver label for new messages divider */ +"chats.divider.newMessagesAccessibility" = "Separador de mensagens novas"; + +// MARK: - Tips + +/* Location: DeviceMenuTip.swift - Purpose: Tip title introducing device menu */ +"chats.tip.deviceMenu.title" = "Menu do dispositivo"; + +/* Location: DeviceMenuTip.swift - Purpose: Tip message explaining device menu features */ +"chats.tip.deviceMenu.message" = "Faça a gestão da ligação, envie Adverts e consulte a bateria. O rádio permanece ligado mesmo depois de sair da aplicação à força. Toque em Desligar para interromper a ligação."; + +// MARK: - Block Sender Sheet + +/* Location: BlockSenderSheet.swift - Purpose: Sheet title with sender name */ +"chats.blockSender.title" = "Bloquear \"%@\""; + +/* Location: BlockSenderSheet.swift - Purpose: Explanation of name-based blocking limitation */ +"chats.blockSender.limitation" = "As mensagens de canal não incluem a identidade do remetente. Este bloqueio corresponde apenas pelo nome — o remetente pode alterar o nome para o contornar."; + +/* Location: BlockSenderSheet.swift - Purpose: Section header when matching contacts found */ +"chats.blockSender.matchingContacts" = "Os contactos seguintes partilham este nome. Selecione os que também devem ser bloqueados:"; + +/* Location: BlockSenderSheet.swift - Purpose: Cancel button */ +"chats.blockSender.cancel" = "Cancelar"; + +/* Location: BlockSenderSheet.swift - Purpose: Destructive block button */ +"chats.blockSender.blockAnyway" = "Bloquear"; + +// MARK: - Contact Match Row + +/* Location: ContactMatchRow.swift - Purpose: Accessibility value when contact is selected */ +"chats.contactMatch.accessibility.selected" = "Selecionado"; + +/* Location: ContactMatchRow.swift - Purpose: Accessibility value when contact is not selected */ +"chats.contactMatch.accessibility.notSelected" = "Não selecionado"; + +/* Location: ContactMatchRow.swift - Purpose: Public key label */ +"chats.contactMatch.key" = "Chave: %@"; + +// MARK: - Send DM Sheet + +/* Location: SendDMSheet.swift - Purpose: Sheet title with sender name */ +"chats.sendDM.title" = "Enviar DM a \"%@\""; + +/* Location: SendDMSheet.swift - Purpose: Name-based matching limitation warning */ +"chats.sendDM.limitation" = "As mensagens de canal não incluem a identidade do remetente. Esta correspondência é apenas pelo nome — o contacto pode ser outra pessoa com o mesmo nome."; + +/* Location: SendDMSheet.swift - Purpose: Section header listing matching contacts */ +"chats.sendDM.matchingContacts" = "Selecione um contacto para enviar mensagem:"; + +/* Location: SendDMSheet.swift - Purpose: Empty-state message when no contact matches */ +"chats.sendDM.noMatches" = "Não foi encontrado nenhum contacto chamado \"%@\"."; + +/* Location: SendDMSheet.swift - Purpose: Cancel button */ +"chats.sendDM.cancel" = "Cancelar"; + +// MARK: - Malware Warning + +/* Location: MalwareWarningCard.swift - Warning title for flagged domains */ +"chats.malwareWarning.title" = "Ligação assinalada como suspeita"; + +/* Location: MalwareWarningCard.swift - Accessibility label - %@ is domain */ +"chats.malwareWarning.accessibility" = "Aviso: ligação para %@ assinalada como suspeita"; + +// MARK: - Region Filtering + +/* Location: ChannelInfoSheet.swift - Purpose: Region row label */ +"chats.channelInfo.region" = "Região"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ +"chats.channelInfo.region.allRegions" = "Sem âmbito"; + +/* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ +"chats.channelInfo.region.useDefaultFormat" = "%@ (predefinição)"; + +/* Location: ChannelInfoSheet.swift - Purpose: Region value when no regions configured */ +"chats.channelInfo.region.notConfigured" = "Não configurada"; + +/* Location: ChannelInfoSheet.swift - Purpose: Explanation shown when no regions exist */ +"chats.channelInfo.region.explanation" = "Limitar as mensagens a uma área geográfica"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button */ +"chats.channelInfo.region.discover" = "Descobrir regiões próximas"; + +/* Location: ChannelInfoSheet.swift - Purpose: Discover button loading state */ +"chats.channelInfo.region.discovering" = "A descobrir…"; + +/* Location: RegionManagementView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.manage" = "Regiões"; + +/* Location: RegionManagementView.swift - Purpose: Manage regions link */ +"chats.channelInfo.region.manageRegions" = "Gerir regiões"; + +/* Location: RegionManagementView.swift - Purpose: Add manually button */ +"chats.channelInfo.region.addManually" = "Adicionar manualmente"; + +/* Location: RegionManagementView.swift - Purpose: Empty state title */ +"chats.channelInfo.region.noRegions" = "Nenhuma região adicionada"; + +/* Location: RegionManagementView.swift - Purpose: Empty state description */ +"chats.channelInfo.region.noRegionsDescription" = "Descubra regiões em repetidores próximos ou adicione-as manualmente."; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No new regions found */ +"chats.channelInfo.region.noNewRegions" = "Nenhuma região nova encontrada"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: No repeaters responded */ +"chats.channelInfo.region.noRepeatersResponded" = "Nenhum repetidor respondeu"; + +/* Location: ChannelInfoSheet.swift - Purpose: Couldn't load nearby repeater list */ +"chats.channelInfo.region.errLoadingRepeaters" = "Não foi possível carregar os repetidores próximos"; + +/* Location: ChannelInfoSheet.swift - Purpose: Some queries failed because radio contact list is full */ +"chats.channelInfo.region.errRadioContactsFull" = "A lista de contactos do rádio está cheia — alguns repetidores não puderam ser consultados"; + +/* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ +"chats.channelInfo.region.addSelected" = "Adicionar"; + +/* Location: AddRegionView.swift - Purpose: Navigation title */ +"chats.channelInfo.region.addRegionTitle" = "Adicionar região"; + +/* Location: AddRegionView.swift - Purpose: Text field placeholder */ +"chats.channelInfo.region.addRegionPlaceholder" = "Nome da região"; + +/* Location: AddRegionView.swift - Purpose: Validation error */ +"chats.channelInfo.region.invalidName" = "Os nomes de região só podem conter letras, números e hífenes."; + +/* Location: RegionManagementView.swift - Purpose: Validation error when a region name exceeds the firmware byte cap */ +"chats.channelInfo.region.nameTooLong" = "Os nomes de região estão limitados a %d bytes."; + +/* Location: AddRegionView.swift - Purpose: Duplicate error */ +"chats.channelInfo.region.duplicate" = "Esta região já está na lista."; + +/* Location: ChannelInfoSheet.swift - Purpose: Private region label */ +"chats.channelInfo.region.private" = "Privada"; + +/* Location: ChatConversationType.swift - Purpose: Subtitle suffix when channel region matches the device default flood scope */ +"chats.channelInfo.region.scopedDefault" = "%@ (predefinição)"; + +// MARK: - Common + +/* Location: ChatConversationView.swift - Conversation toolbar Info button */ +"chats.common.info" = "Informação"; + +// MARK: - Mention Picker + +/* Location: ChatConversationView.swift - Mention picker navigation title shown with the tapped mention name */ +"chats.mention.picker.title" = "Menção: %@"; + +/* Location: ChatConversationView.swift - Mention picker title when the tapped name matches no saved contact */ +"chats.mention.picker.notSavedTitle" = "Não é um contacto guardado"; + +/* Location: ChatConversationView.swift - Mention picker description when the tapped name matches no saved contact */ +"chats.mention.picker.notSavedSubtitle" = "@%@ não corresponde a nenhum contacto neste rádio."; + +/* Location: ChatConversationView.swift - Mention picker title when the tapped name is the user's own node */ +"chats.mention.picker.selfTitle" = "É o nodo local"; + +/* Location: ChatConversationView.swift - Mention picker description when the tapped name is the user's own node */ +"chats.mention.picker.selfSubtitle" = "@%@ é o nome deste nodo."; + +/* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ +"chats.mention.picker.matchingContacts" = "Contactos correspondentes"; + +// MARK: - Unverified Nickname + +/* Location: UnifiedMessageBubble.swift - Title for unverified nickname explanation popover */ +"chats.message.sender.unverifiedNickname" = "Nome não verificado"; + +/* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ +"chats.message.sender.unverifiedNicknameExplanation" = "Os remetentes de mensagens de canal não podem ser verificados. Pode ser outra pessoa."; + +/* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ +"chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Correspondência de nome não verificado"; + +/* Location: UnifiedMessageBubble.swift - Format string for unverified nickname display - %@ is the nickname */ +"chats.message.sender.unverifiedNicknameFormat" = "(%@)"; diff --git a/MC1/Resources/Localization/pt.lproj/Contacts.strings b/MC1/Resources/Localization/pt.lproj/Contacts.strings new file mode 100644 index 000000000..a9e1a630c --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Contacts.strings @@ -0,0 +1,1250 @@ +/* + Contacts.strings + MC1 + + European Portuguese (pt-PT) translation of contacts and nodes strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Common + +/* Location: Multiple files - Purpose: Generic OK button */ +"contacts.common.ok" = "OK"; + +/* Location: Multiple files - Purpose: Generic Cancel button */ +"contacts.common.cancel" = "Cancelar"; + +/* Location: Multiple files - Purpose: Generic Save button */ +"contacts.common.save" = "Guardar"; + +/* Location: Multiple files - Purpose: Generic Done button */ +"contacts.common.done" = "OK"; + +/* Location: Multiple files - Purpose: Generic Delete button */ +"contacts.common.delete" = "Eliminar"; + +/* Location: Multiple files - Purpose: Generic Edit button */ +"contacts.common.edit" = "Editar"; + +/* Location: Multiple files - Purpose: Generic Error alert title */ +"contacts.common.error" = "Erro"; + +/* Location: Multiple files - Purpose: Fallback error message */ +"contacts.common.errorOccurred" = "Ocorreu um erro"; + +// MARK: - Contact Types + +/* Location: Multiple files - Purpose: Chat contact type label */ +"contacts.nodeKind.chat" = "Chat"; + +/* Location: Multiple files - Purpose: Chat contact full label */ +/* Location: Multiple files - Purpose: Repeater contact type label */ +"contacts.nodeKind.repeater" = "Repetidor"; + +/* Location: Multiple files - Purpose: Room contact type label */ +"contacts.nodeKind.room" = "Sala"; + +/* Location: TracePathListView.swift, PathEditingSheet.swift - Purpose: Badge label for discovered nodes */ +"contacts.nodeKind.discovered" = "Descoberto"; + +/* Location: ContactsListView.swift - Purpose: Contact label in search results */ +"contacts.nodeKind.contact" = "Contacto"; + +// MARK: - Route Types + +/* Location: ContactDetailView.swift, ContactRowView.swift - Purpose: Flood routing label */ +"contacts.route.flood" = "Flood"; + +/* Location: ContactDetailView.swift, ContactRowView.swift - Purpose: Direct routing label */ +"contacts.route.direct" = "Direto"; + +/* Location: ContactRowView.swift - Purpose: Hops count display */ +"contacts.route.hops" = "%d saltos"; + +// MARK: - Node Segments + +/* Location: ContactsViewModel.swift - Purpose: Favorites segment */ +"contacts.segment.favorites" = "Favoritos"; + +/* Location: ContactsViewModel.swift - Purpose: Contacts segment */ +"contacts.segment.contacts" = "Contactos"; + +/* Location: ContactsViewModel.swift - Purpose: Repeaters segment */ +"contacts.segment.repeaters" = "Repetidores"; + +/* Location: ContactsViewModel.swift - Purpose: Rooms segment */ +"contacts.segment.rooms" = "Salas"; + +/* Location: NodeSegmentPicker.swift - Purpose: VoiceOver label for the Nodes tab segment filter picker (iOS 18 fallback) */ +"contacts.segment.pickerLabel" = "Filtrar nodos"; + +// MARK: - Sort Order + +/* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ +"contacts.sort.lastHeard" = "Última receção"; + +/* Location: ContactsViewModel.swift - Purpose: Name sort option */ +"contacts.sort.name" = "Nome"; + +/* Location: ContactsViewModel.swift - Purpose: Distance sort option */ +"contacts.sort.distance" = "Distância"; + +/* Location: ContactsViewModel.swift - Purpose: Hops sort option */ +"contacts.sort.hops" = "Saltos"; + +// MARK: - Contacts List + +/* Location: ContactsListView.swift - Purpose: Navigation title */ +"contacts.list.title" = "Nodos"; + +/* Location: ContactsListView.swift - Purpose: Search prompt */ +"contacts.list.searchPrompt" = "Pesquisar nodos"; + +/* Location: ContactsListView.swift - Purpose: Search prompt with count */ +"contacts.list.searchPromptWithCount" = "Pesquisar nodos (%d)"; + +/* Location: ContactsListView.swift - Purpose: Sort menu label */ +"contacts.list.sort" = "Ordenar"; + +/* Location: ContactsListView.swift - Purpose: Options menu label */ +"contacts.list.options" = "Opções"; + +/* Location: ContactsListView.swift - Purpose: Menu item for blocked contacts */ +"contacts.list.blockedContacts" = "Contactos bloqueados"; + +/* Location: ContactsListView.swift - Purpose: Menu item to share own contact */ +"contacts.list.shareMyContact" = "Partilhar o meu contacto"; + +/* Location: ContactsListView.swift - Purpose: Menu item to add contact */ +"contacts.list.addContact" = "Adicionar contacto"; + +/* Location: ContactsListView.swift - Purpose: Menu item for discovery */ +"contacts.list.discover" = "Descobrir"; + +/* Location: ContactsListView.swift - Purpose: Menu item to sync nodes */ +"contacts.list.syncNodes" = "Sincronizar nodos"; + +/* Location: ContactsListView.swift - Purpose: Empty state for split view */ +"contacts.list.selectNode" = "Selecione um nodo"; + +/* Location: ContactsListView.swift - Purpose: Refresh alert title */ +"contacts.list.cannotRefresh" = "Não é possível atualizar"; + +/* Location: ContactsListView.swift - Purpose: Refresh alert message */ +"contacts.list.connectToSync" = "Ligue o dispositivo para sincronizar os contactos."; + +/* Location: ContactsListView.swift - Purpose: Location alert title */ +"contacts.list.locationUnavailable" = "Localização indisponível"; + +/* Location: ContactsListView.swift - Purpose: Location settings button */ +"contacts.list.openSettings" = "Abrir Definições"; + +/* Location: ContactsListView.swift - Purpose: Location alert message */ +"contacts.list.distanceRequiresLocation" = "A ordenação por distância requer acesso à localização."; + +/* Location: ContactsListView.swift - Purpose: VoiceOver offline announcement */ +"contacts.list.offlineAnnouncement" = "A mostrar contactos em cache. Ligue o dispositivo para atualizar."; + +// MARK: - Contacts List Empty States + +/* Location: ContactsListView.swift - Purpose: No favorites empty title */ +"contacts.list.empty.favorites.title" = "Ainda não há favoritos"; + +/* Location: ContactsListView.swift - Purpose: No favorites empty description */ +"contacts.list.empty.favorites.description" = "Toque e mantenha premido qualquer nodo para o adicionar aos favoritos."; + +/* Location: ContactsListView.swift - Purpose: No contacts empty title */ +"contacts.list.empty.contacts.title" = "Nenhum contacto"; + +/* Location: ContactsListView.swift - Purpose: No contacts empty description */ +"contacts.list.empty.contacts.description" = "Os contactos aparecem quando são descobertos na rede mesh. Se a adição automática de contactos estiver desativada, consulte Descobrir no menu no canto superior direito."; + +/* Location: ContactsListView.swift - Purpose: No repeaters empty title */ +"contacts.list.empty.repeaters.title" = "Nenhum repetidor"; + +/* Location: ContactsListView.swift - Purpose: No repeaters empty description */ +"contacts.list.empty.repeaters.description" = "Os repetidores alargam o alcance da mesh. Aparecem aqui quando forem descobertos."; + +/* Location: ContactsListView.swift - Purpose: No rooms empty title */ +"contacts.list.empty.rooms.title" = "Nenhuma sala"; + +/* Location: ContactsListView.swift - Purpose: No rooms empty description */ +"contacts.list.empty.rooms.description" = "As salas são servidores de chat públicos na mesh. Aparecem aqui quando forem descobertas."; + +/* Location: ContactsListView.swift - Purpose: No search results title */ +"contacts.list.empty.search.title" = "Nenhum resultado"; + +/* Location: ContactsListView.swift - Purpose: No search results description */ +"contacts.list.empty.search.description" = "Nenhum nodo corresponde a '%@'"; + +// MARK: - Contacts List Row + +/* Location: ContactRowView.swift - Purpose: Blocked status accessibility label */ +"contacts.row.blocked" = "Bloqueado"; + +/* Location: ContactRowView.swift - Purpose: Favorite status accessibility label */ +"contacts.row.favorite" = "Favorito"; + +/* Location: ContactRowView.swift - Purpose: Location indicator accessibility label */ +"contacts.row.location" = "Localização"; + +/* Location: ContactRowView.swift - Purpose: Distance suffix */ +"contacts.row.away" = "a %@"; + +// MARK: - Contacts List Swipe Actions + +/* Location: ContactContextMenuModifier - Purpose: Unfavorite action */ +"contacts.action.unfavorite" = "Remover dos favoritos"; + +/* Location: ContactContextMenuModifier - Purpose: Block action */ +"contacts.action.block" = "Bloquear"; + +/* Location: ContactContextMenuModifier - Purpose: Unblock action */ +"contacts.action.unblock" = "Desbloquear"; + +// MARK: - Contact Detail + +/* Location: ContactDetailView.swift - Purpose: Favorite status indicator */ +"contacts.detail.favorite" = "Favorito"; + +/* Location: ContactDetailView.swift - Purpose: Blocked status indicator */ +"contacts.detail.blocked" = "Bloqueado"; + +/* Location: ContactDetailView.swift - Purpose: Has location status indicator */ +"contacts.detail.hasLocation" = "Tem localização"; + +/* Location: ContactDetailView.swift - Purpose: Join room button */ +"contacts.detail.joinRoom" = "Entrar na sala"; + +/* Location: ContactDetailView.swift - Purpose: Telemetry button */ +"contacts.detail.telemetry" = "Telemetria"; + +/* Location: ContactDetailView.swift - Purpose: Saved History button for offline telemetry */ +"contacts.detail.savedHistory" = "Histórico de telemetria"; + +/* Location: ContactDetailView.swift - Purpose: Telemetry access sheet title */ +"contacts.detail.telemetryAccess" = "Acesso à telemetria"; + +/* Location: ContactDetailView.swift - Purpose: Management button */ +"contacts.detail.management" = "Gestão"; + +/* Location: ContactDetailView.swift - Purpose: Send message button */ +"contacts.detail.sendMessage" = "Enviar mensagem"; + +/* Location: ContactDetailView.swift - Purpose: Add to favorites button */ +"contacts.detail.addToFavorites" = "Adicionar aos favoritos"; + +/* Location: ContactDetailView.swift - Purpose: Remove from favorites button */ +"contacts.detail.removeFromFavorites" = "Remover dos favoritos"; + +/* Location: ContactDetailView.swift - Purpose: Share contact button */ +"contacts.detail.shareContact" = "Partilhar contacto"; + +/* Location: ContactDetailView.swift - Purpose: Share via advert button */ +"contacts.detail.shareViaAdvert" = "Partilhar contacto via Advert"; + +/* Location: ContactDetailView.swift - Purpose: Share contact error when advert is missing or stale */ +"contacts.detail.shareContactUnavailable" = "Não foi possível partilhar o nodo. O Advert do nodo pode estar em falta ou ser demasiado antigo."; + +/* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ +"contacts.detail.ping" = "Ping zero-hop"; + +/* Location: ContactDetailView.swift - Purpose: Ping no response message */ +"contacts.detail.pingNoResponse" = "Sem resposta"; + +/* Location: ContactDetailView.swift - Purpose: Ping success accessibility label */ +"contacts.detail.pingSuccessLabel" = "Ping bem-sucedido, %1$d milissegundos, %2$d decibéis na ida, %3$d no regresso"; + +/* Location: ContactDetailView.swift - Purpose: Ping failure accessibility label */ +"contacts.detail.pingFailureLabel" = "Falha no ping: %@"; + +/* Location: ContactDetailView.swift - Purpose: Ping success VoiceOver announcement */ +"contacts.detail.pingSuccessAnnouncement" = "Ping bem-sucedido, %d milissegundos"; + +/* Location: ContactDetailView.swift - Purpose: Ping failure VoiceOver announcement */ +"contacts.detail.pingFailureAnnouncement" = "Falha no ping"; + +// MARK: - Contact Detail Info Section + +/* Location: ContactDetailView.swift - Purpose: Info section header */ +"contacts.detail.info" = "Informações"; + +/* Location: ContactDetailView.swift - Purpose: Avatar edit menu title */ +"contacts.detail.avatar.chooseSource" = "Alterar foto de perfil"; + +/* Location: ContactDetailView.swift - Purpose: Avatar edit menu option to pick a photo from the photo library */ +"contacts.detail.avatar.choosePhoto" = "Escolher foto"; + +/* Location: ContactDetailView.swift - Purpose: Avatar edit menu option to pick an image file */ +"contacts.detail.avatar.chooseFile" = "Escolher ficheiro..."; + +/* Location: ContactDetailView.swift - Purpose: Avatar edit menu option to remove the current photo */ +"contacts.detail.avatar.removePhoto" = "Remover foto"; + +/* Location: ContactDetailView.swift - Purpose: Error shown when the picked file isn't a valid image */ +"contacts.detail.avatar.invalidImage" = "Não foi possível usar esse ficheiro como foto de perfil."; + +/* Location: AvatarCropView.swift - Purpose: Crop screen navigation title */ +"contacts.detail.avatar.crop.title" = "Mover e ajustar escala"; + +/* Location: AvatarCropView.swift - Purpose: Crop screen confirm button */ +"contacts.detail.avatar.crop.choose" = "Escolher"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver label for the crop preview */ +"contacts.detail.avatar.crop.preview" = "Recorte da foto de perfil"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver hint for zoom and move */ +"contacts.detail.avatar.crop.previewHint" = "Deslize para cima ou para baixo para ampliar. Use as ações para mover a foto."; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo up */ +"contacts.detail.avatar.crop.moveUp" = "Mover para cima"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo down */ +"contacts.detail.avatar.crop.moveDown" = "Mover para baixo"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo left */ +"contacts.detail.avatar.crop.moveLeft" = "Mover para a esquerda"; + +/* Location: AvatarCropView.swift - Purpose: VoiceOver action that pans the photo right */ +"contacts.detail.avatar.crop.moveRight" = "Mover para a direita"; + +/* Location: ContactDetailView.swift - Purpose: VoiceOver suffix announced while a new profile picture is being saved */ +"contacts.detail.avatar.savingAnnouncement" = "A guardar foto"; + +/* Location: ContactDetailView.swift - Purpose: Nickname label */ +"contacts.detail.nickname" = "Alcunha"; + +/* Location: ContactDetailView.swift - Purpose: No nickname placeholder */ +"contacts.detail.nicknameNone" = "Nenhuma"; + +/* Location: ContactDetailView.swift - Purpose: Name label */ +"contacts.detail.name" = "Nome"; + +/* Location: ContactDetailView.swift - Purpose: Last advert label */ +"contacts.detail.lastAdvert" = "Último Advert"; + +/* Location: ContactDetailView.swift - Purpose: Last heard (phone-clock on-air) label */ +"contacts.detail.lastHeard" = "Última receção"; + +/* Location: ContactDetailView.swift - Purpose: Unread messages label */ +"contacts.detail.unreadMessages" = "Mensagens por ler"; + +// MARK: - Contact Detail Location Section + +/* Location: ContactDetailView.swift - Purpose: Location section header */ +"contacts.detail.location" = "Localização"; + +/* Location: ContactDetailView.swift - Purpose: Coordinates label */ +"contacts.detail.coordinates" = "Coordenadas"; + +/* Location: ContactDetailView.swift - Purpose: Open in Maps button */ +"contacts.detail.openInMaps" = "Abrir em Mapas"; + +// MARK: - Contact Detail Network Path Section + +/* Location: ContactDetailView.swift - Purpose: Outbound path section header */ +"contacts.detail.outboundPath" = "Caminho de saída"; + +/* Location: ContactDetailView.swift - Purpose: Route label */ +"contacts.detail.route" = "Rota"; + +/* Location: ContactDetailView.swift - Purpose: Copy route to clipboard */ +"contacts.detail.copyRoute" = "Copiar rota"; + +/* Location: ContactDetailView.swift - Purpose: Hops away label */ +"contacts.detail.hopsAway" = "Distância em saltos"; + +/* Location: ContactDetailView.swift - Purpose: Path discovery in progress */ +"contacts.detail.discoveringPath" = "A descobrir o caminho..."; + +/* Location: ContactDetailView.swift - Purpose: Discovery countdown */ +"contacts.detail.secondsRemaining" = "Até %d segundos restantes"; + +/* Location: ContactDetailView.swift - Purpose: Discover path button */ +"contacts.detail.discoverPath" = "Descobrir caminho"; + +/* Location: ContactDetailView.swift - Purpose: Edit path button */ +"contacts.detail.editPath" = "Editar caminho"; + +/* Location: ContactDetailView.swift - Purpose: Reset path button */ +"contacts.detail.resetPath" = "Repor caminho"; + +/* Location: ContactDetailView.swift - Purpose: Footer for flood routing */ +"contacts.detail.floodFooter" = "As mensagens são enviadas a todos os nodos. Use Descobrir caminho para encontrar uma rota ideal."; + +/* Location: ContactDetailView.swift - Purpose: Footer for path routing */ +"contacts.detail.pathFooter" = "As mensagens seguem o caminho mostrado. Repor o caminho para usar o encaminhamento Flood."; + +/* Location: ContactDetailView.swift - Purpose: Accessibility label for flood route */ +"contacts.detail.routeFlood" = "Rota: Flood"; + +/* Location: ContactDetailView.swift - Purpose: Accessibility label for direct route */ +"contacts.detail.routeDirect" = "Rota: Direto"; + +/* Location: ContactDetailView.swift - Purpose: Accessibility label prefix for route */ +"contacts.detail.routePrefix" = "Rota: %@"; + +// MARK: - Contact Detail Technical Section + +/* Location: ContactDetailView.swift - Purpose: Technical section header */ +"contacts.detail.technical" = "Técnico"; + +/* Location: ContactDetailView.swift - Purpose: Public key label */ +"contacts.detail.publicKey" = "Chave pública"; + +/* Location: ContactDetailView.swift - Purpose: Type label */ +"contacts.detail.type" = "Tipo"; + +// MARK: - Contact Detail Danger Section + +/* Location: ContactDetailView.swift - Purpose: Danger zone section header */ +"contacts.detail.dangerZone" = "Zona de perigo"; + +/* Location: ContactDetailView.swift - Purpose: Clear messages button */ +"contacts.detail.clearMessages" = "Limpar mensagens"; + +/* Location: ContactDetailView.swift - Purpose: Clear messages services-unavailable error */ +"contacts.detail.error.servicesUnavailable" = "Serviços indisponíveis"; + +/* Location: ContactDetailView.swift - Purpose: Block contact button */ +"contacts.detail.blockContact" = "Bloquear contacto"; + +/* Location: ContactDetailView.swift - Purpose: Unblock contact button */ +"contacts.detail.unblockContact" = "Desbloquear contacto"; + +/* Location: ContactDetailView.swift - Purpose: Delete button with type */ +"contacts.detail.deleteType" = "Eliminar %@"; + +// MARK: - Contact Detail Alerts + +/* Location: ContactDetailView.swift - Purpose: Block contact alert title */ +"contacts.detail.alert.block.title" = "Bloquear contacto"; + +/* Location: ContactDetailView.swift - Purpose: Block contact alert message */ +"contacts.detail.alert.block.message" = "Não serão recebidas mensagens de %@. As conversas desse contacto ficam ocultas na lista de Chats e as novas mensagens de canal são descartadas. Ao desbloquear, as novas mensagens passam a ser permitidas, mas as mensagens descartadas não podem ser recuperadas."; + +/* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ +"contacts.detail.alert.delete.title" = "Eliminar %@"; + +/* Location: ContactDetailView.swift - Purpose: Delete contact alert message */ +"contacts.detail.alert.delete.message" = "Isto irá remover %@ e eliminar todos os dados associados. Esta ação não pode ser anulada."; + +/* Location: ContactDetailView.swift - Purpose: Clear messages alert title */ +"contacts.detail.alert.clearMessages.title" = "Limpar mensagens?"; + +/* Location: ContactDetailView.swift - Purpose: Clear messages alert message */ +"contacts.detail.alert.clearMessages.message" = "Todas as mensagens com %@ serão eliminadas de forma permanente."; + +/* Location: ContactDetailView.swift - Purpose: Path error alert title */ +"contacts.detail.alert.pathError" = "Erro de caminho"; + +/* Location: ContactDetailView.swift - Purpose: Path discovery alert title */ +"contacts.detail.alert.pathDiscovery" = "Descoberta de caminho"; + +// MARK: - Blocked Contacts + +/* Location: BlockedContactsView.swift - Purpose: Navigation title */ +"contacts.blocked.title" = "Contactos bloqueados"; + +/* Location: BlockedContactsView.swift - Purpose: Loading progress */ +"contacts.blocked.loading" = "A carregar..."; + +/* Location: BlockedContactsView.swift - Purpose: Empty state title */ +"contacts.blocked.empty.title" = "Nenhum contacto bloqueado"; + +/* Location: BlockedContactsView.swift - Purpose: Empty state description */ +"contacts.blocked.empty.description" = "Os contactos bloqueados aparecem aqui."; + +// MARK: - Add Contact Sheet + +/* Location: AddContactSheet.swift - Purpose: Navigation title */ +"contacts.add.title" = "Adicionar contacto"; +"contacts.add.nodeTitle" = "Adicionar nodo"; + +/* Location: AddContactSheet.swift - Purpose: Add button */ +"contacts.add.add" = "Adicionar"; + +/* Location: AddContactSheet.swift - Purpose: Scan QR button label */ +"contacts.add.scanQR" = "Digitalizar código QR"; + +/* Location: AddContactSheet.swift - Purpose: Type section header */ +"contacts.add.type" = "Tipo"; + +/* Location: AddContactSheet.swift - Purpose: Name section header */ +"contacts.add.name" = "Nome"; + +/* Location: AddContactSheet.swift - Purpose: Contact name placeholder */ +"contacts.add.contactName" = "Nome do contacto"; + +/* Location: AddContactSheet.swift - Purpose: Public key section header */ +"contacts.add.publicKey" = "Chave pública"; + +/* Location: AddContactSheet.swift - Purpose: Public key placeholder */ +"contacts.add.hexPlaceholder" = "%d caracteres hexadecimais"; + +/* Location: AddContactSheet.swift - Purpose: Valid key status */ +"contacts.add.valid" = "Válida"; + +/* Location: AddContactSheet.swift - Purpose: Character count status */ +"contacts.add.characterCount" = "%d/%d caracteres"; + +/* Location: AddContactSheet.swift - Purpose: Public key footer */ +"contacts.add.publicKeyFooter" = "Introduza a chave pública hexadecimal de %d caracteres do contacto"; + +/* Location: AddContactSheet.swift - Purpose: Not connected error */ +"contacts.add.error.notConnected" = "Não ligado ao dispositivo"; + +/* Location: AddContactSheet.swift - Purpose: Invalid public key format error */ +"contacts.add.error.invalidFormat" = "Formato de chave pública inválido"; + +/* Location: AddContactSheet.swift - Purpose: Invalid public key size error */ +"contacts.add.error.invalidSize" = "A chave pública tem de ter %d bytes (%d caracteres hexadecimais)"; + +/* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error with max count */ +"contacts.add.error.nodeListFull" = "A lista de nodos está cheia (máximo de %d nodos)"; + +/* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ +"contacts.add.error.nodeListFullSimple" = "A lista de nodos está cheia"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL button label */ +"contacts.add.pasteURL" = "Colar URL do contacto"; + +/* Location: AddContactSheet.swift - Purpose: Paste URL section footer */ +"contacts.add.pasteURLFooter" = "Cole uma ligação meshcore:// de contacto para preencher automaticamente os campos acima"; + +/* Location: AddContactSheet.swift - Purpose: Invalid paste URL error */ +"contacts.add.error.invalidURL" = "A área de transferência não contém um URL de contacto válido"; + +// MARK: - Contact QR Share Sheet + +/* Location: ContactQRShareSheet.swift - Purpose: Navigation title */ +"contacts.qr.title" = "Partilhar contacto"; + +/* Location: ContactQRShareSheet.swift - Purpose: Share subject */ +"contacts.qr.shareSubject" = "Contacto MeshCore One"; + +/* Location: ContactQRShareSheet.swift - Purpose: Copy button when not copied */ +"contacts.qr.copy" = "Copiar"; + +/* Location: ContactQRShareSheet.swift - Purpose: Copy button when copied */ +"contacts.qr.copied" = "Copiado!"; + +/* Location: ContactQRShareSheet.swift - Purpose: Share button */ +"contacts.qr.share" = "Partilhar"; + +/* Location: ContactQRShareSheet.swift - Purpose: Contact label in share text, %@ is contact name */ +"contacts.share.contactLabel" = "Contacto MeshCore One: %@"; + +/* Location: ContactQRShareSheet.swift - Purpose: Key label in share text, %@ is hex public key */ +"contacts.share.keyLabel" = "Chave: %@"; + +// MARK: - Scan Contact QR + +/* Location: ScanContactQRView.swift - Purpose: Navigation title */ +"contacts.scan.title" = "Digitalizar código QR"; + +/* Location: ScanContactQRView.swift - Purpose: Scanner not available title */ +"contacts.scan.unavailable.title" = "Scanner indisponível"; + +/* Location: ScanContactQRView.swift - Purpose: Scanner not available description */ +"contacts.scan.unavailable.description" = "A digitalização de códigos QR não é suportada neste dispositivo"; + +/* Location: ScanContactQRView.swift - Purpose: Importing progress */ +"contacts.scan.importing" = "A importar o contacto..."; + +/* Location: ScanContactQRView.swift - Purpose: Scan instruction */ +"contacts.scan.instruction" = "Aponte a câmara para um código QR de contacto"; + +/* Location: ScanContactQRView.swift - Purpose: Camera permission title */ +"contacts.scan.permission.title" = "É necessário o acesso à câmara"; + +/* Location: ScanContactQRView.swift - Purpose: Camera permission description */ +"contacts.scan.permission.description" = "Ative o acesso à câmara em Definições para digitalizar códigos QR."; + +/* Location: ScanContactQRView.swift - Purpose: Invalid QR format error */ +"contacts.scan.error.invalidFormat" = "Formato de código QR inválido"; + +/* Location: ScanContactQRView.swift - Purpose: Missing name error */ +/* Location: ScanContactQRView.swift - Purpose: Invalid public key error */ +/* Location: ScanContactQRView.swift - Purpose: Contact import failed, %@ is the error description */ +"contacts.scan.error.importFailed" = "Falha ao importar o contacto: %@"; + +// MARK: - Discovery View + +/* Location: DiscoveryView.swift - Purpose: Navigation title */ +"contacts.discovery.title" = "Descobrir"; + +/* Location: DiscoveryView.swift - Purpose: Empty state title */ +"contacts.discovery.empty.title" = "Nenhum nodo descoberto"; + +/* Location: DiscoveryView.swift - Purpose: Empty state description */ +"contacts.discovery.empty.description" = "Os nodos aparecem aqui à medida que os respetivos Adverts são descobertos."; + +/* Location: DiscoveryView.swift - Purpose: Add button */ +"contacts.discovery.add" = "Adicionar"; + +/* Location: DiscoveryView.swift - Purpose: Button label when node is already added */ +"contacts.discovery.added" = "Adicionado"; + +/* Location: DiscoveryView.swift - Purpose: Accessibility label for added button */ +"contacts.discovery.addedAccessibility" = "Já adicionado aos contactos"; + +/* Location: DiscoveryView.swift - Purpose: Services not available error */ +/* Location: DiscoveryView.swift - Purpose: Segment filter: All */ +"contacts.discovery.segment.all" = "Todos"; + +/* Location: DiscoveryView.swift - Purpose: Segment filter: Contacts */ +"contacts.discovery.segment.contacts" = "Contactos"; + +/* Location: DiscoveryView.swift - Purpose: Segment filter: Repeaters */ +"contacts.discovery.segment.repeaters" = "Repetidores"; + +/* Location: DiscoveryView.swift - Purpose: Segment filter: Rooms */ +"contacts.discovery.segment.rooms" = "Salas"; + +/* Location: DiscoverSegmentPicker.swift - Purpose: VoiceOver label for the Discovery segment filter picker (iOS 18 fallback) */ +"contacts.discovery.segment.pickerLabel" = "Filtrar nodos descobertos"; + +/* Location: DiscoveryView.swift - Purpose: Search prompt */ +"contacts.discovery.searchPrompt" = "Pesquisar descobertos"; + +/* Location: DiscoveryView.swift - Purpose: More menu accessibility label */ +"contacts.discovery.menu" = "Mais"; + +/* Location: DiscoveryView.swift - Purpose: Clear all menu item */ +"contacts.discovery.clear" = "Limpar tudo"; + +/* Location: DiscoveryView.swift - Purpose: Clear confirmation title */ +"contacts.discovery.clear.title" = "Limpar todos os nodos descobertos?"; + +/* Location: DiscoveryView.swift - Purpose: Clear confirmation message */ +"contacts.discovery.clear.message" = "Os nodos descobertos serão removidos desta lista, mas podem ser descobertos novamente na rede mesh."; + +/* Location: DiscoveryView.swift - Purpose: Clear confirmation button */ +"contacts.discovery.clear.confirm" = "Limpar"; + +/* Location: DiscoveryView.swift - Purpose: VoiceOver announcement when searching */ +"contacts.discovery.searchingAllTypes" = "A pesquisar todos os tipos"; + +/* Location: DiscoveryView.swift - Purpose: Sort menu accessibility label */ +"contacts.discovery.sortMenu" = "Opções de ordenação"; + +/* Location: DiscoveryView.swift - Purpose: Sort menu accessibility hint */ +"contacts.discovery.sortMenuHint" = "Escolha como ordenar os nodos descobertos"; + +/* Location: DiscoveryView.swift - Purpose: VoiceOver announcement after clearing */ +"contacts.discovery.clearedAllNodes" = "Todos os nodos descobertos foram limpos"; + +/* Location: DiscoveryView.swift - Purpose: Swipe action to remove discovered node */ +"contacts.discovery.remove" = "Remover"; + +/* Location: DiscoveryView.swift - Purpose: Search empty state title */ +"contacts.discovery.empty.search.title" = "Nenhum resultado"; + +/* Location: DiscoveryView.swift - Purpose: Search empty state description */ +"contacts.discovery.empty.search.description" = "Nenhum nodo descoberto corresponde a '%@'"; + +// MARK: - Path Editing Sheet + +/* Location: PathEditingSheet.swift - Purpose: Navigation title */ +"contacts.pathEdit.title" = "Editar caminho"; + +/* Location: PathEditingSheet.swift - Purpose: Description with contact name */ +"contacts.pathEdit.description" = "Personalize a rota que as mensagens seguem para chegar a %@."; + +/* Location: PathEditingSheet.swift - Purpose: Current path section header */ +"contacts.pathEdit.currentPath" = "Caminho atual"; + +/* Location: AddHopPickerView.swift - Purpose: No repeaters empty title */ +"contacts.pathEdit.noRepeaters.title" = "Nenhum repetidor disponível"; + +/* Location: AddHopPickerView.swift - Purpose: No repeaters empty description */ +"contacts.pathEdit.noRepeaters.description" = "Os repetidores aparecem aqui depois de serem descobertos na rede mesh."; + +/* Location: AddHopPickerView.swift - Purpose: No favorite repeaters empty title */ +"contacts.pathEdit.noFavorites.title" = "Nenhum repetidor favorito"; + +/* Location: AddHopPickerView.swift - Purpose: No favorite repeaters empty description */ +"contacts.pathEdit.noFavorites.description" = "Os repetidores marcados como favoritos aparecem aqui."; + +/* Location: AddHopPickerView.swift - Purpose: No recent repeaters empty title */ +"contacts.pathEdit.noRecent.title" = "Nenhum repetidor recente"; + +/* Location: AddHopPickerView.swift - Purpose: No recent repeaters empty description */ +"contacts.pathEdit.noRecent.description" = "Os repetidores adicionados a um caminho aparecem aqui."; + +/* Location: PathEditingSheet.swift - Purpose: Hop accessibility with name */ +"contacts.pathEdit.hopWithName" = "Salto %d de %d: %@"; + +/* Location: PathEditingSheet.swift - Purpose: Hop accessibility with hex */ +"contacts.pathEdit.hopWithHex" = "Salto %d de %d: repetidor %@"; + +/* Location: PathEditingSheet.swift - Purpose: Add Hop CTA (primary button when path has >=1 hop) */ +"contacts.pathEdit.addHop" = "Adicionar salto"; + +/* Location: AddHopPickerView.swift - Purpose: Paste clipboard text into the hop search field */ +"contacts.pathEdit.paste" = "Colar da área de transferência"; + +/* Location: PathEditingSheet.swift - Purpose: Use-direct-routing empty-state button */ +"contacts.pathEdit.useDirectRouting" = "Usar encaminhamento direto"; + +/* Location: PathEditingSheet.swift - Purpose: Use-flood-routing empty-state button */ +"contacts.pathEdit.useFloodRouting" = "Usar encaminhamento Flood"; + +/* Location: PathEditingSheet.swift - Purpose: Direct-routing confirmation alert title */ +"contacts.pathEdit.directRouting.confirm.title" = "Guardar como encaminhamento direto?"; + +/* Location: PathEditingSheet.swift - Purpose: Direct-routing confirmation alert message, %@ is contact name */ +"contacts.pathEdit.directRouting.confirm.message" = "As mensagens para %@ serão enviadas diretamente, sem passar por nenhum repetidor. Use apenas se %@ for um vizinho direto (0-hop)."; + +/* Location: PathEditingSheet.swift - Purpose: Direct-routing confirmation confirm label */ +"contacts.pathEdit.directRouting.confirm.confirm" = "Guardar como direto"; + +/* Location: PathEditingSheet.swift - Purpose: Flood-routing confirmation alert title */ +"contacts.pathEdit.floodRouting.confirm.title" = "Usar encaminhamento Flood?"; + +/* Location: PathEditingSheet.swift - Purpose: Flood-routing confirmation alert message, %@ is contact name */ +"contacts.pathEdit.floodRouting.confirm.message" = "As mensagens para %@ serão enviadas a todos os nodos próximos até ser encontrado um caminho. Use quando não for conhecida nenhuma rota de repetidor."; + +/* Location: PathEditingSheet.swift - Purpose: Flood-routing confirmation confirm label */ +"contacts.pathEdit.floodRouting.confirm.confirm" = "Usar encaminhamento Flood"; + +/* Location: AddHopPickerView.swift - Purpose: Position banner when appending, %d is target hop number */ +"contacts.pathEdit.positionAppend" = "A adicionar como salto %d"; + +/* Location: AddHopPickerView.swift - Purpose: Search field placeholder */ +"contacts.pathEdit.searchPrompt" = "A1,2B ou nome/ID"; + +/* Location: PathEditingSheet.swift - Purpose: Section footer hint */ +"contacts.pathEdit.reorderHint" = "Arraste para reordenar · Deslize para a esquerda para remover"; + +/* Location: PathEditingSheet.swift - Purpose: Empty-state title */ +"contacts.pathEdit.empty.title" = "Ainda não há saltos"; + +/* Location: PathEditingSheet.swift - Purpose: Empty-state description, %@ is contact name */ +"contacts.pathEdit.empty.description" = "Adicione repetidores para controlar como as mensagens chegam a %@ ou use o encaminhamento Flood se o caminho não for conhecido."; + +/* Location: AddHopSegmentPicker.swift - Purpose: All filter */ +"contacts.pathEdit.filter.all" = "Todos"; + +/* Location: AddHopSegmentPicker.swift - Purpose: Favorites filter */ +"contacts.pathEdit.filter.favorites" = "Favoritos"; + +/* Location: AddHopSegmentPicker.swift - Purpose: Recent filter */ +"contacts.pathEdit.filter.recent" = "Recentes"; + +/* Location: AddHopSegmentPicker.swift - Purpose: Discovered filter */ +"contacts.pathEdit.filter.discovered" = "Descobertos"; + +/* Location: AddHopPickerView.swift - Purpose: Recent section header */ +"contacts.pathEdit.sections.recent" = "Recentes"; + +/* Location: AddHopPickerView.swift - Purpose: Favorites section header */ +"contacts.pathEdit.sections.favorites" = "Favoritos"; + +/* Location: AddHopPickerView.swift - Purpose: Contacts section header */ +"contacts.pathEdit.sections.contacts" = "Contactos"; + +/* Location: AddHopPickerView.swift - Purpose: Discovered section header */ +"contacts.pathEdit.sections.discovered" = "Descobertos"; + +/* Location: AddHopPickerView.swift - Purpose: Rooms section header */ +"contacts.pathEdit.sections.rooms" = "Salas"; + +/* Location: AddHopPickerView.swift - Purpose: No-match search description */ +"contacts.pathEdit.search.noMatches.description" = "Tente um nome ou um prefixo hexadecimal como 'a3'."; + +/* Location: AddHopPickerView.swift - Purpose: No-match description when a filter other than All is selected and a matching room exists */ +"contacts.pathEdit.search.noMatches.descriptionWithRoomsHint" = "Tente um nome ou um prefixo hexadecimal como 'a3'. As salas só aparecem no filtro Todos."; + +/* Location: AddHopPickerView.swift - Purpose: Picker row accessibility label, %1$@ is name, %2$d is target hop number */ +"contacts.pathEdit.addToPathAsHop" = "Adicionar %1$@ ao caminho como salto %2$d"; + +/* Location: PathEditingSheet.swift - Purpose: VoiceOver hint for a hop row describing swipe + drag actions */ +"contacts.pathEdit.hopHint" = "Deslize para eliminar. Arraste para reordenar."; + +/* Location: AddHopSegmentPicker.swift - Purpose: VoiceOver label for the section filter picker */ +"contacts.pathEdit.filterPickerLabel" = "Filtrar saltos"; + +/* Location: PathEditingSheet.swift + AddHopPickerView.swift - Purpose: Max-hops reached title */ +"contacts.pathEdit.maxHops.reached" = "Máximo de saltos atingido"; + +/* Location: AddHopPickerView.swift - Purpose: Max-hops reached description, %d is the cap */ +"contacts.pathEdit.maxHops.description" = "Este caminho atingiu o máximo de %d saltos no modo de hash atual. Remova um salto para adicionar outro."; + +/* Location: PathEditingSheet.swift - Purpose: Add Hop CTA footer explaining the hop cap, %d is the cap */ +"contacts.pathEdit.maxHops.footer" = "Máximo de %d saltos atingido. Remova um salto para adicionar outro."; + +/* Location: AddHopPickerView.swift - Purpose: Bulk-add action button, %@ is the comma-joined codes */ +"contacts.pathEdit.bulkAdd.action" = "Adicionar nodos: %@"; + +/* Location: AddHopPickerView.swift - Purpose: Bulk-add row, code will be added, %@ is the code */ +"contacts.pathEdit.bulkAdd.willAdd" = "%@ será adicionado"; + +/* Location: AddHopPickerView.swift - Purpose: Bulk-add row, code skipped past the hop cap, %@ is the code */ +"contacts.pathEdit.bulkAdd.pathFull" = "%@ excede o limite de saltos"; + +/* Location: AddHopPickerView.swift - Purpose: Bulk-add action button when nothing is addable */ +"contacts.pathEdit.bulkAdd.empty" = "Não há nodos novos para adicionar"; + +// MARK: - Path Discovery Results + +/* Location: PathManagementViewModel.swift - Purpose: Direct path result */ +"contacts.pathDiscovery.direct" = "Direto"; + +/* Location: PathManagementViewModel.swift - Purpose: Hop count result singular */ +"contacts.pathDiscovery.hops.singular" = "1 salto"; + +/* Location: PathManagementViewModel.swift - Purpose: Hop count result plural */ +"contacts.pathDiscovery.hops.plural" = "%d saltos"; + +/* Location: PathManagementViewModel.swift - Purpose: No response message */ +"contacts.pathDiscovery.noResponse" = "O nodo remoto não respondeu. Os nodos precisam de ter os pedidos de telemetria ativados para responder à descoberta de caminho."; + +/* Location: PathManagementViewModel.swift - Purpose: Failed prefix */ +"contacts.pathDiscovery.failed" = "Falha: %@"; + +// MARK: - Saved Paths Sheet + +/* Location: SavedPathsSheet.swift - Purpose: Navigation title */ +"contacts.savedPaths.title" = "Caminhos guardados"; + +/* Location: SavedPathsSheet.swift - Purpose: Delete dialog title */ +"contacts.savedPaths.deleteTitle" = "Eliminar caminho"; + +/* Location: SavedPathsSheet.swift - Purpose: Delete dialog message */ +"contacts.savedPaths.deleteMessage" = "Eliminar \"%@\"? Isto irá remover o caminho e todo o histórico de execuções."; + +/* Location: SavedPathsSheet.swift - Purpose: Rename alert title */ +"contacts.savedPaths.renameTitle" = "Renomear caminho"; + +/* Location: SavedPathsSheet.swift - Purpose: Rename context menu */ +"contacts.savedPaths.rename" = "Renomear"; + +/* Location: SavedPathsSheet.swift - Purpose: Empty state title */ +"contacts.savedPaths.empty.title" = "Nenhum caminho guardado"; + +/* Location: SavedPathsSheet.swift - Purpose: Empty state description */ +"contacts.savedPaths.empty.description" = "Guarde caminhos depois de executar traçados para os voltar a executar mais tarde."; + +/* Location: SavedPathsSheet.swift - Purpose: Run count singular */ +"contacts.savedPaths.runs.singular" = "1 execução"; + +/* Location: SavedPathsSheet.swift - Purpose: Run count plural */ +"contacts.savedPaths.runs.plural" = "%d execuções"; + +/* Location: SavedPathsSheet.swift - Purpose: Last run label */ +"contacts.savedPaths.lastRun" = "Última: %@"; + +/* Location: SavedPathsSheet.swift - Purpose: Healthy status accessibility */ +"contacts.savedPaths.health.healthy" = "bom"; + +/* Location: SavedPathsSheet.swift - Purpose: Degraded status accessibility */ +"contacts.savedPaths.health.degraded" = "degradado"; + +/* Location: SavedPathsSheet.swift - Purpose: Poor status accessibility */ +"contacts.savedPaths.health.poor" = "fraco"; + +/* Location: SavedPathsSheet.swift - Purpose: Health accessibility label */ +"contacts.savedPaths.healthLabel" = "Estado do caminho: %@, taxa de sucesso de %d%%"; + +/* Location: SavedPathsSheet.swift - Purpose: Response times accessibility */ +"contacts.savedPaths.responseTimes" = "Tempos de resposta: média de %dms, %@"; + +/* Location: SavedPathsSheet.swift - Purpose: No response data accessibility */ +"contacts.savedPaths.noResponseData" = "Sem dados de tempos de resposta"; + +/* Location: SavedPathsSheet.swift - Purpose: Trend increasing */ +"contacts.savedPaths.trend.increasing" = "a aumentar"; + +/* Location: SavedPathsSheet.swift - Purpose: Trend decreasing */ +"contacts.savedPaths.trend.decreasing" = "a diminuir"; + +/* Location: SavedPathsSheet.swift - Purpose: Trend stable */ +"contacts.savedPaths.trend.stable" = "estável"; + +// MARK: - Saved Path Detail + +/* Location: SavedPathDetailView.swift - Purpose: Path section header */ +"contacts.pathDetail.path" = "Caminho"; + +/* Location: SavedPathDetailView.swift - Purpose: Performance section header */ +"contacts.pathDetail.performance" = "Desempenho"; + +/* Location: SavedPathDetailView.swift - Purpose: Chart Y axis label */ +"contacts.pathDetail.roundTripMs" = "Ida e volta (ms)"; + +/* Location: SavedPathDetailView.swift - Purpose: Average stat label */ +"contacts.pathDetail.avg" = "Média"; + +/* Location: SavedPathDetailView.swift - Purpose: Best stat label */ +"contacts.pathDetail.best" = "Melhor"; + +/* Location: SavedPathDetailView.swift - Purpose: Success stat label */ +"contacts.pathDetail.success" = "Sucesso"; + +/* Location: SavedPathDetailView.swift - Purpose: History section header */ +"contacts.pathDetail.history" = "Histórico"; + +/* Location: SavedPathDetailView.swift - Purpose: Failed status */ +"contacts.pathDetail.failed" = "Falhou"; + +/* Location: SavedPathDetailView.swift - Purpose: Run details navigation title */ +"contacts.pathDetail.runDetails" = "Detalhes da execução"; + +/* Location: SavedPathDetailView.swift - Purpose: Overview section header */ +"contacts.pathDetail.overview" = "Visão geral"; + +/* Location: SavedPathDetailView.swift - Purpose: Date label */ +"contacts.pathDetail.date" = "Data"; + +/* Location: SavedPathDetailView.swift - Purpose: Round trip label */ +"contacts.pathDetail.roundTrip" = "Ida e volta"; + +/* Location: SavedPathDetailView.swift - Purpose: Status label */ +"contacts.pathDetail.status" = "Estado"; + +/* Location: SavedPathDetailView.swift - Purpose: Per-hop SNR section header */ +"contacts.pathDetail.perHopSNR" = "SNR por salto"; + +/* Location: SavedPathDetailView.swift - Purpose: Hop label */ +"contacts.pathDetail.hop" = "Salto %d"; + +// MARK: - Trace Path View + +/* Location: TracePathView.swift - Purpose: Navigation title */ +"contacts.trace.title" = "Traçar caminho"; + +/* Location: TracePathView.swift - Purpose: View mode picker label */ +"contacts.trace.viewMode" = "Modo de visualização"; + +/* Location: TracePathView.swift - Purpose: List view mode */ +"contacts.trace.mode.list" = "Lista"; + +/* Location: TracePathView.swift - Purpose: Map view mode */ +"contacts.trace.mode.map" = "Mapa"; + +/* Location: TracePathView.swift - Purpose: Saved toolbar button */ +"contacts.trace.saved" = "Guardados"; + +/* Location: TracePathView.swift - Purpose: Clear path dialog title */ +"contacts.trace.clearPath" = "Limpar caminho"; + +/* Location: TracePathView.swift - Purpose: Clear path dialog message */ +"contacts.trace.clearPathMessage" = "Remover todos os repetidores do caminho?"; + +/* Location: TracePathView.swift - Purpose: Trace failed alert title */ +"contacts.trace.failed" = "Falha no traçado"; + +/* Location: TracePathView.swift - Purpose: Jump button label */ +"contacts.trace.runBelow" = "Ir para Executar"; + +/* Location: TracePathView.swift - Purpose: Jump button accessibility label */ +"contacts.trace.jumpLabel" = "Ir para o botão Executar traçado"; + +/* Location: TracePathView.swift - Purpose: Jump button accessibility hint */ +"contacts.trace.jumpHint" = "Toque duas vezes para ir até ao fim do caminho"; + +// MARK: - Trace Path List View + +/* Location: TracePathListView.swift - Purpose: Empty path instruction */ +"contacts.trace.list.emptyPath" = "Adicione um salto para começar a montar o caminho."; + +/* Location: TracePathListView.swift - Purpose: Round trip path section header */ +"contacts.trace.list.roundTripPath" = "Caminho de ida e volta"; + +/* Location: TracePathListView.swift - Purpose: Auto return toggle label */ +"contacts.trace.list.autoReturn" = "Caminho de regresso automático"; + +/* Location: TracePathListView.swift - Purpose: Auto return toggle description */ +"contacts.trace.list.autoReturnDescription" = "Espelhar o caminho de saída no regresso"; + +/* Location: TracePathListView.swift - Purpose: Batch trace toggle label */ +"contacts.trace.list.batchTrace" = "Traçado em lote"; + +/* Location: TracePathListView.swift - Purpose: Batch trace toggle description */ +"contacts.trace.list.batchTraceDescription" = "Executar vários traçados e calcular a média dos resultados"; + +/* Location: TracePathListView.swift - Purpose: Traces count label */ +"contacts.trace.list.traces" = "Traçados:"; + +/* Location: PathActionsSectionView.swift - Purpose: Trace hash size picker label */ +"contacts.trace.list.hashSize" = "Tamanho do hash"; + +/* Location: PathActionsSectionView.swift - Purpose: Trace hash size option, 1 byte per hop */ +"contacts.trace.list.hashSizeOneByte" = "1 byte"; + +/* Location: PathActionsSectionView.swift - Purpose: Trace hash size option, 2 bytes per hop */ +"contacts.trace.list.hashSizeTwoBytes" = "2 bytes"; + +/* Location: PathActionsSectionView.swift - Purpose: Trace hash size option, 4 bytes per hop */ +"contacts.trace.list.hashSizeFourBytes" = "4 bytes"; + +/* Location: PathActionsSectionView.swift - Purpose: Caveat shown for multi-byte trace hash sizes */ +"contacts.trace.list.hashSizeFooter" = "Os repetidores com firmware anterior a 1.11.0 não reencaminham traçados com um tamanho de hash superior a 1 byte."; + +/* Location: TracePathListView.swift - Purpose: Copy path button */ +"contacts.trace.list.copyPath" = "Copiar caminho"; + +/* Location: TracePathListView.swift - Purpose: Range warning footer */ +"contacts.trace.list.rangeWarning" = "É necessário estar ao alcance do último repetidor para receber uma resposta."; + +/* Location: TracePathListView.swift - Purpose: Running trace progress */ +"contacts.trace.list.runningTrace" = "A executar o traçado"; + +/* Location: TracePathListView.swift - Purpose: Running trace with batch count */ +"contacts.trace.list.runningBatch" = "A executar o traçado %d de %d"; + +/* Location: TracePathListView.swift - Purpose: Run trace button */ +"contacts.trace.list.runTrace" = "Executar traçado"; + +/* Location: TracePathListView.swift - Purpose: Run trace accessibility label */ +"contacts.trace.list.runTraceLabel" = "Executar traçado"; + +/* Location: TracePathListView.swift - Purpose: Batch run accessibility hint */ +"contacts.trace.list.batchHint" = "Toque duas vezes para executar %d traçados"; + +/* Location: TracePathListView.swift - Purpose: Single run accessibility hint */ +"contacts.trace.list.singleHint" = "Toque duas vezes para traçar o caminho"; + +/* Location: TracePathListView.swift - Purpose: Running accessibility label */ +"contacts.trace.list.runningLabel" = "A executar o traçado, aguarde"; + +/* Location: TracePathListView.swift - Purpose: Running batch accessibility label */ +"contacts.trace.list.runningBatchLabel" = "A executar o traçado %d de %d"; + +/* Location: TracePathListView.swift - Purpose: Hop row accessibility label */ +"contacts.trace.list.hopLabel" = "Salto %d: %@"; + +/* Location: TracePathListView.swift - Purpose: Hop row accessibility hint */ +"contacts.trace.list.hopHint" = "Deslize para a esquerda para eliminar; use o puxador para reordenar"; + +// MARK: - Trace Path List Accessibility + +/* Location: TracePathListView.swift - Accessibility hint when trace is running */ +"contacts.trace.list.runningHint" = "O traçado está em curso"; + +// MARK: - Trace Path Cluster View + +/* Location: TracePathClusterView.swift - Accessibility label for cluster annotation - %d is count */ +/* Location: TracePathClusterView.swift - Accessibility hint for cluster annotation */ +// MARK: - Trace Path Repeater Pin View + +/* Location: TracePathRepeaterPinView.swift - Accessibility label for repeater in path - %1$@ is name, %2$d is hop number */ +/* Location: TracePathRepeaterPinView.swift - Accessibility hint for removable last hop */ +/* Location: TracePathRepeaterPinView.swift - Accessibility hint for non-removable hop */ +/* Location: TracePathRepeaterPinView.swift - Accessibility label for available repeater - %@ is name */ +/* Location: TracePathRepeaterPinView.swift - Accessibility hint for adding repeater to path */ +// MARK: - Trace Path Map View + +/* Location: TracePathMapView.swift - Purpose: Hops count in results banner */ +"contacts.trace.map.hops" = "%d saltos"; + +/* Location: TracePathMapView.swift - Purpose: Clear button */ +"contacts.trace.map.clear" = "Limpar"; + +/* Location: TracePathMapView.swift - Purpose: Hide labels accessibility */ +/* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ +/* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ +"contacts.trace.map.centerOnPath" = "Centrar no caminho"; + +/* Location: TracePathMapView.swift - Purpose: Save path alert title */ +"contacts.trace.map.saveTitle" = "Guardar caminho"; + +/* Location: TracePathMapView.swift - Purpose: Save path alert message */ +"contacts.trace.map.saveMessage" = "Introduza um nome para este caminho"; + +/* Location: TracePathMapView.swift - Purpose: Path name placeholder */ +"contacts.trace.map.pathName" = "Nome do caminho"; + +/* Location: TracePathMapView.swift - Purpose: Path saved alert title */ +"contacts.trace.map.savedTitle" = "Caminho guardado"; + +/* Location: TracePathMapView.swift - Purpose: Path saved alert message */ +"contacts.trace.map.savedMessage" = "O caminho foi guardado."; + +/* Location: TracePathMapView.swift - Purpose: Save failed alert title */ +"contacts.trace.map.saveFailedTitle" = "Falha ao guardar"; + +/* Location: TracePathMapView.swift - Purpose: Save failed alert message */ +"contacts.trace.map.saveFailedMessage" = "Não foi possível guardar o caminho. Tente novamente."; + +/* Location: TracePathMapView.swift - Purpose: View results button */ +"contacts.trace.map.viewResults" = "Resultados"; + +/* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ +"contacts.trace.map.defaultPathName" = "Caminho"; + +// MARK: - Trace Results Sheet + +/* Location: TraceResultsSheet.swift - Purpose: Navigation title */ +"contacts.results.title" = "Resultados do traçado"; + +/* Location: TraceResultsSheet.swift - Purpose: Dismiss button */ +"contacts.results.dismiss" = "Fechar"; + +/* Location: TraceResultsSheet.swift - Purpose: Batch success count */ +"contacts.results.batchSuccess" = "%d de %d bem-sucedidos (%d%%)"; + +/* Location: TraceResultsSheet.swift - Purpose: Batch progress */ +"contacts.results.batchProgress" = "A executar o traçado %d de %d..."; + +/* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ +"contacts.results.batchCompleteLabel" = "Lote concluído: %d de %d traçados bem-sucedidos (%d%%)"; + +/* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ +"contacts.results.batchProgressLabel" = "Progresso do lote: traçado %d de %d"; + +/* Location: TraceResultsSheet.swift - Purpose: Average round trip label */ +"contacts.results.avgRoundTrip" = "Ida e volta média"; + +/* Location: TraceResultsSheet.swift - Purpose: Average RTT accessibility */ +"contacts.results.avgRTTLabel" = "Ida e volta média: %d milissegundos, intervalo de %d a %d"; + +/* Location: TraceResultsSheet.swift - Purpose: Comparison text */ +"contacts.results.comparison" = "vs. %d ms em %@"; + +/* Location: ComparisonRowView.swift - Purpose: Accessibility labels for change direction */ +"contacts.results.comparison.increased" = "Aumentou"; +"contacts.results.comparison.decreased" = "Diminuiu"; + +/* Location: TraceResultsSheet.swift - Purpose: View runs link */ +"contacts.results.viewRuns" = "Ver %d execuções"; + +/* Location: TraceResultsSheet.swift - Purpose: Total distance label */ +"contacts.results.totalDistance" = "Distância total"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance unavailable */ +"contacts.results.unavailable" = "Indisponível"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance info button accessibility */ +"contacts.results.distanceInfo" = "Info de distância"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance unavailable accessibility */ +"contacts.results.distanceUnavailableLabel" = "Distância indisponível"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance info hint */ +"contacts.results.distanceInfoHint" = "Toque duas vezes para ver detalhes sobre localizações em falta"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance info navigation title (unavailable) */ +"contacts.results.distanceInfoTitle" = "Distância indisponível"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance info navigation title (partial) */ +"contacts.results.distanceInfoTitlePartial" = "Informações de distância"; + +/* Location: TraceResultsSheet.swift - Purpose: Partial distance explanation */ +"contacts.results.partialDistanceExplanation" = "A distância mostrada é apenas entre repetidores. A distância do dispositivo até ao primeiro repetidor não está incluída porque a localização do dispositivo está indisponível."; + +/* Location: TraceResultsSheet.swift - Purpose: Partial distance section header */ +"contacts.results.partialDistanceHeader" = "Distância parcial"; + +/* Location: TraceResultsSheet.swift - Purpose: Full path tip */ +"contacts.results.fullPathTip" = "Ative os serviços de localização ou defina uma localização para o dispositivo para ver a distância completa do caminho."; + +/* Location: TraceResultsSheet.swift - Purpose: Full path section header */ +"contacts.results.fullPathHeader" = "Para incluir o caminho completo"; + +/* Location: TraceResultsSheet.swift - Purpose: Partial distance accessibility label */ +"contacts.results.partialDistanceLabel" = "Distância parcial"; + +/* Location: TraceResultsSheet.swift - Purpose: Partial distance accessibility hint */ +"contacts.results.partialDistanceHint" = "Toque duas vezes para saber porque é que a localização do dispositivo está excluída"; + +/* Location: TraceResultsSheet.swift - Purpose: Distance needs repeaters message */ +"contacts.results.needsRepeaters" = "O cálculo da distância exige pelo menos 2 repetidores no caminho."; + +/* Location: TraceResultsSheet.swift - Purpose: Distance error message */ +"contacts.results.distanceError" = "Não é possível calcular a distância. Todos os repetidores têm coordenadas, mas ocorreu um erro."; + +/* Location: TraceResultsSheet.swift - Purpose: Distance missing locations message */ +"contacts.results.missingLocations" = "Não é possível calcular a distância porque os seguintes repetidores não têm coordenadas de localização definidas."; + +/* Location: TraceResultsSheet.swift - Purpose: Repeaters without locations section */ +"contacts.results.repeatersWithoutLocations" = "Repetidores sem localização"; + +/* Location: TraceResultsSheet.swift - Purpose: Save path button */ +"contacts.results.savePath" = "Guardar caminho"; + +// MARK: - Trace Result Hop Row + +/* Location: TraceResultsSheet.swift - Purpose: My Device placeholder */ +"contacts.results.hop.myDevice" = "O meu dispositivo"; + +/* Location: TraceResultsSheet.swift - Purpose: Started trace label */ +"contacts.results.hop.started" = "Traçado iniciado"; + +/* Location: TraceResultsSheet.swift - Purpose: Received response label */ +"contacts.results.hop.received" = "Resposta recebida"; + +/* Location: TraceResultsSheet.swift - Purpose: Repeated label */ +"contacts.results.hop.repeated" = "Repetido"; + +/* Location: TraceResultsSheet.swift - Purpose: Average SNR display */ +"contacts.results.hop.avgSNR" = "SNR médio: %@ dB (%@ – %@)"; + +/* Location: TraceResultsSheet.swift - Purpose: SNR display */ +"contacts.results.hop.snr" = "SNR: %@ dB"; + +/* Location: TraceResultsSheet.swift - Purpose: Average SNR accessibility */ +"contacts.results.hop.avgSNRLabel" = "Relação sinal/ruído média: %@ decibéis, intervalo de %@ a %@"; + +// MARK: - Trace ViewModel Error Messages + +/* Location: TracePathViewModel.swift - Purpose: No response error */ +"contacts.trace.error.noResponse" = "Nenhuma resposta recebida"; + +/* Location: TracePathViewModel.swift - Purpose: All traces failed error */ +"contacts.trace.error.allFailed" = "Todos os %d traçados falharam"; + +/* Location: TracePathViewModel.swift - Purpose: Send failed error */ +"contacts.trace.error.sendFailed" = "Falha ao enviar o pacote de traçado"; + +// MARK: - Stats Badge Accessibility + +/* Location: StatsBadgeView.swift - Purpose: Distance and signal accessibility */ +// MARK: - Contact ViewModel Errors + +/* Location: ContactsViewModel.swift - Purpose: Delete requires connection error */ +"contacts.viewModel.connectToDelete" = "Ligue o dispositivo para eliminar nodos"; + +/* Location: ContactsViewModel.swift - Purpose: Delete radio command timed out error */ +"contacts.viewModel.removeTimedOut" = "A eliminação do nodo expirou. Tente novamente."; + +// MARK: - Path Management ViewModel Errors + +/* Location: PathManagementViewModel.swift - Purpose: Save path error prefix */ +"contacts.pathManagement.error.saveFailed" = "Falha ao guardar o caminho: %@"; + +/* Location: PathManagementViewModel.swift - Purpose: Reset path error prefix */ +"contacts.pathManagement.error.resetFailed" = "Falha ao repor o caminho: %@"; + +/* Location: PathManagementViewModel.swift - Purpose: Set path error prefix */ +"contacts.pathManagement.error.setFailed" = "Falha ao definir o caminho: %@"; + +/* Location: PathManagementViewModel.swift - Purpose: Shown when a stored path's hop can't be resized to the device's current hash size */ +"contacts.pathManagement.error.hopResizeRequired" = "O tamanho do hash do caminho mudou — remova e volte a adicionar os saltos para guardar."; + +/* Location: PathManagementViewModel.swift - Purpose: Shown when the existing path has more hops than the current hash mode supports */ +"contacts.pathManagement.error.tooManyHops" = "Demasiados saltos. O modo de hash atual suporta no máximo %d."; + +// MARK: - Code Input Result + +/* Location: TracePathViewModel.swift - Purpose: Invalid format error */ +"contacts.codeInput.error.invalidFormat" = "Formato inválido: %@"; + +/* Location: TracePathViewModel.swift - Purpose: Not found error */ +"contacts.codeInput.error.notFound" = "%@ não encontrado"; + +/* Location: TracePathViewModel.swift - Purpose: Already in path error */ +"contacts.codeInput.error.alreadyInPath" = "%@ já está no caminho"; + +// MARK: - Path Name Generation + +/* Location: TracePathViewModel.swift - Purpose: Default path name prefix for hash-only paths */ +"contacts.pathName.prefix" = "Caminho %@"; + +/* Location: TracePathViewModel.swift - Purpose: Path name with two endpoints */ +"contacts.pathName.twoEndpoints" = "%@ → %@"; + +/* Location: TracePathViewModel.swift - Purpose: Path name with multiple endpoints (abbreviated) */ +"contacts.pathName.multipleEndpoints" = "%@ → ... → %@"; diff --git a/MC1/Resources/Localization/pt.lproj/Localizable.strings b/MC1/Resources/Localization/pt.lproj/Localizable.strings new file mode 100644 index 000000000..766abed0a --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Localizable.strings @@ -0,0 +1,628 @@ +/* + Localizable.strings + MC1 + + European Portuguese (pt-PT) translation of common and shared strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Generic Buttons + +/* Standard confirmation button for dialogs */ +"common.ok" = "OK"; + +/* Standard cancel button for dialogs and sheets */ +"common.cancel" = "Cancelar"; + +/* Standard done button for completing an action */ +"common.done" = "OK"; + +/* Standard save button for persisting changes */ +"common.save" = "Guardar"; + +/* Standard delete button for removing items */ +"common.delete" = "Eliminar"; + +/* Standard edit button for entering edit mode */ +"common.edit" = "Editar"; + +/* Standard close button for dismissing views */ +/* Button to retry a failed operation */ +"common.tryAgain" = "Tentar novamente"; + +/* Error label when content fails to load */ +"common.error.failedToLoad" = "Falha ao carregar"; + +/* Network error with underlying error description - %@ is the error description */ +"common.error.networkError" = "Erro de rede: %@"; + +/* Invalid response from API */ +"common.error.invalidResponse" = "Resposta inválida da API de elevação"; + +/* API error with message - %@ is the error message */ +"common.error.apiError" = "Erro de API: %@"; + +/* No data returned from API */ +"common.error.noElevationData" = "Não foram devolvidos dados de elevação"; + +/* Rate limited by elevation API */ +"common.error.rateLimited" = "Demasiados pedidos. Tente novamente dentro de instantes."; + +/* Standard remove button for removing items from a list or group */ +// MARK: - Connection Status + +/* Location: SyncingPillView.swift - Status shown when syncing data */ +"common.status.syncing" = "A sincronizar"; + +/* Location: SyncingPillView.swift - Status shown when connecting to device */ +"common.status.connecting" = "A ligar"; + +/* Location: SyncingPillView.swift - Status shown when device is ready */ +"common.status.ready" = "Pronto"; + +/* Location: SyncingPillView.swift - Status shown when device is disconnected */ +"common.status.disconnected" = "Desligado"; + +// MARK: - Tab Bar + +/* Tab bar title for the messaging/conversations screen */ +"tabs.chats" = "Chats"; + +/* VoiceOver value announcing the unread message count on the Chats sidebar icon. %d is the count. */ +"tabs.chatsUnreadAccessibilityValue" = "%d por ler"; + +/* Tab bar title for the nodes/contacts list screen */ +"tabs.nodes" = "Nodos"; + +/* Tab bar title for the map screen showing node locations */ +"tabs.map" = "Mapa"; + +/* Tab bar title for the tools/utilities screen */ +"tabs.tools" = "Ferramentas"; + +/* Tab bar title for the app settings screen */ +"tabs.settings" = "Definições"; + +// MARK: - Connection Alerts + +/* Alert title when device connection fails */ +"alert.connectionFailed.title" = "Falha na ligação"; + +/* Alert title when connection cannot be established */ +"alert.couldNotConnect.title" = "Não foi possível ligar"; + +/* Alert title when device pairing fails (e.g., wrong PIN) */ +"alert.pairingFailed.title" = "Não foi possível emparelhar"; + +/* Default message when device connection fails */ +"alert.connectionFailed.defaultMessage" = "Não foi possível ligar ao dispositivo."; + +/* Message suggesting another app may be connected to the device */ +"alert.couldNotConnect.otherAppMessage" = "Certifique-se de que nenhum outro app está ligado ao dispositivo e tente novamente."; + +/* Button to remove failed pairing and retry connection */ +"alert.connectionFailed.removeAndRetry" = "Remover e tentar novamente"; + +/* VoiceOver label for the destructive Remove and Try Again button — clarifies that "remove" means removing the device pairing */ +"accessibility.alert.connectionFailed.removeAndRetry" = "Remover o emparelhamento do dispositivo e tentar novamente"; + +// MARK: - Accessibility + +/* Location: SyncingPillView.swift - Accessibility hint for disconnected state */ +"common.accessibility.connectHint" = "Toque duas vezes para ligar o dispositivo"; + +/* VoiceOver announcement when viewing cached data while disconnected from device */ +/* Location: View+RadioDisabled.swift - VoiceOver hint when a control is disabled because the radio is not connected */ +"accessibility.requiresRadioConnection" = "Requer ligação de rádio"; + +/* Accessibility value for toggle in On state */ +"accessibility.on" = "Ativado"; + +/* Accessibility value for toggle in Off state */ +"accessibility.off" = "Desativado"; + +// MARK: - Node Types + +/* Node type for a person or contact */ +/* Node type for a mesh network repeater device */ +/* Node type for a group chat room */ +// MARK: - Permission Levels + +/* Permission level with limited access */ +/* Permission level with standard access */ +/* Permission level with full administrative access */ +// MARK: - Reaction Notifications + +/* Notification body when someone reacts to your message - %1$@ is the emoji, %2$@ is the message preview */ +"notifications.reaction.body" = "Reagiu %1$@ à sua mensagem: \"%2$@\""; + +// MARK: - Discovery Notifications + +/* Notification title when a new contact is discovered on the mesh network */ +"notifications.discovery.contact" = "Novo contacto descoberto"; + +/* Notification title when a new repeater node is discovered on the mesh network */ +"notifications.discovery.repeater" = "Novo repetidor descoberto"; + +/* Notification title when a new room is discovered on the mesh network */ +"notifications.discovery.room" = "Nova sala descoberta"; + +// MARK: - Notification Actions + +/* Notification action button to reply to a message */ +"notifications.action.reply" = "Responder"; + +/* Notification action button to send a quick reply */ +"notifications.action.send" = "Enviar"; + +/* Notification action placeholder for quick reply text input */ +"notifications.action.messagePlaceholder" = "Mensagem..."; + +/* Notification action button to mark a message as read */ +"notifications.action.markAsRead" = "Marcar como lida"; + +// MARK: - Low Battery Notifications + +/* Notification title for low battery warning */ +"notifications.lowBattery.title" = "Bateria fraca"; + +/* Notification body for low battery warning - %1$@ is device name, %2$d is battery percentage */ +"notifications.lowBattery.body" = "A bateria de %1$@ está a %2$d%%"; + +/* Notification title when a quick reply fails to send */ +"notifications.quickReplyFailed.title" = "Mensagem não enviada"; + +/* Notification body when a quick reply fails - %@ is the contact or channel name */ +"notifications.quickReplyFailed.body" = "A resposta para %@ não pôde ser enviada."; + +/* Fallback display name for a discovered contact with no advertised name */ +"notifications.discovery.unknownContact" = "Contacto desconhecido"; + +// MARK: - Connection VoiceOver Announcements + +/* VoiceOver announcement when sync fails and device is disconnecting */ +"accessibility.connection.syncFailedDisconnecting" = "Falha na sincronização. A desligar."; + +/* VoiceOver announcement when device connection is lost */ +"accessibility.connection.deviceConnectionLost" = "Ligação ao dispositivo perdida"; + +/* VoiceOver announcement when device reconnects */ +"accessibility.connection.deviceReconnected" = "Dispositivo religado"; + +/* VoiceOver signal-strength descriptors for the device picker signal bars */ +"accessibility.signalStrength.weak" = "Sinal fraco"; +"accessibility.signalStrength.medium" = "Sinal médio"; +"accessibility.signalStrength.strong" = "Sinal forte"; + +// MARK: - Status Pill + +/* Status pill message when sync has failed */ +"statusPill.syncFailed" = "Falha na sincronização"; + +// MARK: - Error Messages + +/* Location: MeshCoreError+UserFacingMessage.swift - Session operation timed out */ +"error.meshCore.timeout" = "A operação expirou. Tente novamente."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Device response could not be parsed - %@ is the parse failure detail */ +"error.meshCore.parseError" = "Falha ao analisar a resposta do dispositivo: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - No active connection to the radio */ +"error.meshCore.notConnected" = "Não ligado ao dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift - A command failed on the device - %@ is the failure reason */ +"error.meshCore.commandFailed" = "O comando falhou: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Unexpected device response - %1$@ is the expected response, %2$@ is the received response */ +"error.meshCore.invalidResponse" = "Resposta inesperada do dispositivo (esperado %1$@, recebido %2$@)."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Contact missing from the radio */ +"error.meshCore.contactNotFound" = "Contacto não encontrado no dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Payload exceeds device limit - %1$lld is the actual size in bytes, %2$lld is the maximum */ +"error.meshCore.dataTooLarge" = "Dados demasiado grandes (%1$lld bytes, o máximo é %2$lld)."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Cryptographic signing failed - %@ is the failure reason */ +"error.meshCore.signingFailed" = "Falha na assinatura: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Invalid input - %@ is the validation detail */ +"error.meshCore.invalidInput" = "Entrada inválida: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Unknown error - %@ is the error detail */ +"error.meshCore.unknown" = "Ocorreu um erro desconhecido: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Bluetooth hardware unavailable */ +"error.meshCore.bluetoothUnavailable" = "O Bluetooth não está disponível neste dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Bluetooth permission denied */ +"error.meshCore.bluetoothUnauthorized" = "É necessária permissão de Bluetooth. Ative-a em Definições."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Bluetooth radio is off */ +"error.meshCore.bluetoothPoweredOff" = "O Bluetooth está desligado. Ative o Bluetooth para ligar."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Connection lost with detail - %@ is the underlying error message */ +"error.meshCore.connectionLost" = "A ligação ao dispositivo foi perdida: %@"; + +/* Location: MeshCoreError+UserFacingMessage.swift - Connection lost without further detail */ +"error.meshCore.connectionLostNoDetail" = "A ligação ao dispositivo foi perdida."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Session not started */ +"error.meshCore.sessionNotStarted" = "A sessão não foi iniciada."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Feature disabled in device firmware */ +"error.meshCore.featureDisabled" = "Esta funcionalidade está desativada no dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: command unsupported */ +"error.device.unsupportedCommand" = "Comando não suportado pelo firmware do dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: item not found */ +"error.device.notFound" = "Item não encontrado no dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: storage full */ +"error.device.storageFull" = "O armazenamento do dispositivo está cheio."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: invalid state */ +"error.device.invalidState" = "O dispositivo está num estado inválido para esta operação."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: file system error */ +"error.device.fileSystem" = "Erro no sistema de ficheiros do dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift, ProtocolError+UserFacingMessage.swift - Firmware error code: invalid parameter */ +"error.device.invalidParameter" = "Parâmetro inválido enviado ao dispositivo."; + +/* Location: MeshCoreError+UserFacingMessage.swift - Firmware error code outside the known range - %lld is the raw code */ +"error.device.unknown" = "Erro do dispositivo (código %lld)."; + +/* Location: TimeoutError+UserFacingMessage.swift - Generic timeout for async operations */ +"error.timeout.operationTimedOut" = "A operação expirou. Tente novamente."; + +/* Location: BLEError+UserFacingMessage.swift - Bluetooth hardware unavailable */ +"error.ble.bluetoothUnavailable" = "O Bluetooth não está disponível neste dispositivo."; + +/* Location: BLEError+UserFacingMessage.swift - Bluetooth permission denied */ +"error.ble.bluetoothUnauthorized" = "É necessária permissão de Bluetooth. Ative-a em Definições."; + +/* Location: BLEError+UserFacingMessage.swift - Bluetooth radio is off */ +"error.ble.bluetoothPoweredOff" = "O Bluetooth está desligado. Ative o Bluetooth para ligar."; + +/* Location: BLEError+UserFacingMessage.swift - Scanned device could not be found */ +"error.ble.deviceNotFound" = "Dispositivo não encontrado. Certifique-se de que está ligado e por perto."; + +/* Location: BLEError+UserFacingMessage.swift - BLE connection failed - %@ is the failure detail */ +"error.ble.connectionFailed" = "Falha na ligação: %@"; + +/* Location: BLEError+UserFacingMessage.swift - BLE connection attempt timed out */ +"error.ble.connectionTimeout" = "A ligação expirou. Tente novamente."; + +/* Location: BLEError+UserFacingMessage.swift - No BLE device connected */ +"error.ble.notConnected" = "Não ligado a um dispositivo."; + +/* Location: BLEError+UserFacingMessage.swift - Required BLE characteristic missing */ +"error.ble.characteristicNotFound" = "Não foi possível comunicar com o dispositivo. Tente religar."; + +/* Location: BLEError+UserFacingMessage.swift - BLE write failed - %@ is the failure detail */ +"error.ble.writeError" = "Falha ao enviar dados: %@"; + +/* Location: BLEError+UserFacingMessage.swift - Malformed BLE response */ +"error.ble.invalidResponse" = "Resposta inválida do dispositivo. Tente novamente."; + +/* Location: BLEError+UserFacingMessage.swift - BLE operation timed out */ +"error.ble.operationTimeout" = "A operação expirou. Tente novamente."; + +/* Location: BLEError+UserFacingMessage.swift - BLE PIN authentication failed */ +"error.ble.authenticationFailed" = "Falha na autenticação. Verifique o PIN do dispositivo."; + +/* Location: BLEError+UserFacingMessage.swift - Bluetooth pairing failed - %@ is the failure reason */ +"error.ble.pairingFailed" = "Falha no emparelhamento Bluetooth: %@"; + +/* Location: BLEError+UserFacingMessage.swift - Radio already in use by another app */ +"error.ble.deviceConnectedToOtherApp" = "Este dispositivo está ligado a outro app. Apenas um app pode usar um rádio mesh de cada vez para evitar problemas de comunicação."; + +/* Location: ConnectionError+UserFacingMessage.swift - Connection failed - %@ is the failure reason */ +"error.connection.connectionFailed" = "Falha na ligação: %@"; + +/* Location: ConnectionError+UserFacingMessage.swift - Device to connect to was not found */ +"error.connection.deviceNotFound" = "Dispositivo não encontrado"; + +/* Location: ConnectionError+UserFacingMessage.swift - No active device connection */ +"error.connection.notConnected" = "Não ligado ao dispositivo"; + +/* Location: ConnectionError+UserFacingMessage.swift - Post-connect device initialization failed - %@ is the failure reason */ +"error.connection.initializationFailed" = "Falha na inicialização do dispositivo: %@"; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection failed - %@ is the failure reason */ +"error.wifi.connectionFailed" = "Falha na ligação: %@"; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ +"error.wifi.connectionTimeout" = "A ligação expirou. Verifique o endereço IP e certifique-se de que o dispositivo está na mesma rede."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ +"error.wifi.notConnected" = "Não ligado ao dispositivo."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send failed - %@ is the failure reason */ +"error.wifi.sendFailed" = "Falha ao enviar dados: %@"; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ +"error.wifi.sendTimeout" = "A operação de envio expirou."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ +"error.wifi.invalidHost" = "Endereço IP inválido."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ +"error.wifi.invalidPort" = "Número de porta inválido."; + +/* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection info missing */ +"error.wifi.notConfigured" = "Ligação não configurada."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - AccessorySetupKit session not active */ +"error.accessorySetup.sessionNotActive" = "O Bluetooth não está pronto. Certifique-se de que o Bluetooth está ativado e tente novamente."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - AccessorySetupKit session invalidated */ +"error.accessorySetup.sessionInvalidated" = "A sessão Bluetooth terminou inesperadamente. Reinicie o app."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - User dismissed the accessory picker */ +"error.accessorySetup.pickerDismissed" = "A seleção do dispositivo foi cancelada."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Accessory picker could not be shown */ +"error.accessorySetup.pickerRestricted" = "Não é possível mostrar o seletor de dispositivos. Verifique se o Bluetooth está ativado, aguarde um momento e tente novamente."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Accessory picker already on screen */ +"error.accessorySetup.pickerAlreadyActive" = "O seletor de dispositivos já está visível."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Accessory pairing failed - %@ is the failure reason */ +"error.accessorySetup.pairingFailed" = "Falha no emparelhamento: %@"; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Picked accessory lacks a Bluetooth identifier */ +"error.accessorySetup.noBluetoothIdentifier" = "O dispositivo selecionado não suporta ligação Bluetooth."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Accessory discovery timed out */ +"error.accessorySetup.discoveryTimeout" = "Nenhum dispositivo encontrado. Certifique-se de que o dispositivo está ligado e por perto."; + +/* Location: AccessorySetupKitError+UserFacingMessage.swift - Connection to picked accessory failed */ +"error.accessorySetup.connectionFailed" = "Não foi possível ligar ao dispositivo. Tente novamente."; + +/* Location: ContactServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.contactService.notConnected" = "Não ligado ao rádio"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Contact operation message failed to send */ +"error.contactService.sendFailed" = "Falha ao enviar mensagem"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Malformed device response during a contact operation */ +"error.contactService.invalidResponse" = "Resposta inválida do dispositivo"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Contact sync stopped before finishing */ +"error.contactService.syncInterrupted" = "A sincronização foi interrompida"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Contact missing from the radio */ +"error.contactService.contactNotFound" = "Contacto não encontrado no dispositivo"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Radio node list has no free slots */ +"error.contactService.contactTableFull" = "A lista de nodos do dispositivo está cheia"; + +/* Location: ContactServiceError+UserFacingMessage.swift - Node advertisement missing or stale, so the node cannot be shared */ +"error.contactService.shareContactUnavailable" = "Não foi possível partilhar o nodo. O Advert do nodo pode estar em falta ou desatualizado."; + +/* Location: MessageServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.messageService.notConnected" = "Não ligado ao dispositivo."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Recipient contact missing from the database */ +"error.messageService.contactNotFound" = "Contacto não encontrado."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Target channel missing from the database */ +"error.messageService.channelNotFound" = "Canal não encontrado."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Message send failed */ +"error.messageService.sendFailed" = "Falha no envio."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Recipient cannot receive direct messages */ +"error.messageService.invalidRecipient" = "Não é possível enviar mensagens para este destinatário."; + +/* Location: MessageServiceError+UserFacingMessage.swift - Message text exceeds the maximum length */ +"error.messageService.messageTooLong" = "A mensagem excede o comprimento máximo permitido."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.channelService.notConnected" = "Não ligado ao dispositivo."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel missing from the database */ +"error.channelService.channelNotFound" = "Canal não encontrado."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel slot index out of range */ +"error.channelService.invalidChannelIndex" = "Índice de canal inválido."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Hashing the channel secret key failed */ +"error.channelService.secretHashingFailed" = "Falha ao gerar o hash da chave secreta do canal."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel save failed - %@ is the failure reason */ +"error.channelService.saveFailed" = "Falha ao guardar o canal: %@"; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel message send failed - %@ is the failure reason */ +"error.channelService.sendFailed" = "Falha no envio: %@"; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel sync already running */ +"error.channelService.syncAlreadyInProgress" = "A sincronização de canais já está em curso."; + +/* Location: ChannelServiceError+UserFacingMessage.swift - Channel sync suspended - %lld is the consecutive failure count */ +"error.channelService.circuitBreakerOpen" = "Sincronização de canais suspensa após %lld falhas consecutivas."; + +/* Location: IntentError.swift - No radio is connected, so retrying alone will not help until one is connected */ +"error.intent.notConnected" = "Nenhum rádio está ligado. Ligue o rádio em MeshCore One e tente novamente."; + +/* Location: IntentError.swift - No single valid recipient was chosen (none, both, or a recipient that cannot receive messages) */ +"error.intent.invalidRecipient" = "Escolha um único destinatário: um contacto ou um canal que possa receber mensagens."; + +/* Location: IntentError.swift - The message text exceeds the maximum length the radio can send */ +"error.intent.messageTooLong" = "A mensagem é demasiado longa para enviar."; + +/* Location: IntentError.swift - Persisting the message to the send queue failed, so it was never queued */ +"error.intent.sendFailed" = "A mensagem não pôde ser colocada na fila de envio. Tente novamente."; + +/* Title of the radio-status Control Center control that opens the app; resolved in the app process */ +"Open MeshCore One" = "Abrir MeshCore One"; + +/* Location: ChatSendQueueServiceError+UserFacingMessage.swift - Queueing a message for send failed - %@ is the underlying error message */ +"error.chatSendQueue.persistFailed" = "Falha ao colocar a mensagem na fila de envio: %@"; + +/* Location: ChatSendQueueServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.chatSendQueue.notConnected" = "Não ligado ao dispositivo."; + +/* Location: MessagePollingError+UserFacingMessage.swift - No active connection to the radio */ +"error.messagePolling.notConnected" = "Não ligado ao dispositivo."; + +/* Location: MessagePollingError+UserFacingMessage.swift - Fetching queued messages from the radio failed */ +"error.messagePolling.pollingFailed" = "Falha ao obter as mensagens em espera."; + +/* Location: AdvertisementError+UserFacingMessage.swift - No active connection to the radio */ +"error.advertisement.notConnected" = "Não ligado ao dispositivo."; + +/* Location: AdvertisementError+UserFacingMessage.swift - Self-advertisement broadcast failed */ +"error.advertisement.sendFailed" = "Falha ao enviar o Advert."; + +/* Location: AdvertisementError+UserFacingMessage.swift - Malformed device response to an advertisement command */ +"error.advertisement.invalidResponse" = "Resposta inválida do dispositivo."; + +/* Location: RemoteNodeError+UserFacingMessage.swift - No active connection to the mesh radio */ +"error.remoteNode.notConnected" = "Não ligado ao dispositivo mesh"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Remote node login failed */ +"error.remoteNode.loginFailed" = "Falha ao iniciar sessão."; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Sending to the remote node failed */ +"error.remoteNode.sendFailed" = "Falha no envio."; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Malformed response from the remote node */ +"error.remoteNode.invalidResponse" = "Resposta inválida do nodo remoto"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Remote node rejected the operation */ +"error.remoteNode.permissionDenied" = "Permissão recusada"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Remote node request timed out */ +"error.remoteNode.timeout" = "O pedido expirou"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - No stored session for the remote node */ +"error.remoteNode.sessionNotFound" = "Sessão do nodo remoto não encontrada"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Saved password missing from the keychain */ +"error.remoteNode.passwordNotFound" = "Palavra-passe não encontrada no Porta-chaves"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Keep-alive needs a direct path but the route is flood-routed */ +"error.remoteNode.floodRouted" = "O keep-alive requer um caminho de encaminhamento direto"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Establishing a direct path to the node failed */ +"error.remoteNode.pathDiscoveryFailed" = "Falha ao estabelecer um caminho direto"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ +"error.remoteNode.contactNotFound" = "Contacto não encontrado na base de dados"; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Radio contact table full; cannot auto-add the node during login */ +"error.remoteNode.radioContactsFull" = "O rádio está sem espaços para contactos. Remova um contacto que já não seja necessário e tente iniciar sessão novamente."; + +/* Location: RemoteNodeError+UserFacingMessage.swift - Login attempt cancelled by a duplicate attempt or shutdown */ +"error.remoteNode.cancelled" = "Início de sessão cancelado"; + +/* Location: RoomServerError+UserFacingMessage.swift - No active connection to the radio */ +"error.roomServer.notConnected" = "Não ligado ao dispositivo."; + +/* Location: RoomServerError+UserFacingMessage.swift - No stored session for the room server */ +"error.roomServer.sessionNotFound" = "Sessão da sala não encontrada."; + +/* Location: RoomServerError+UserFacingMessage.swift - Room message send failed */ +"error.roomServer.sendFailed" = "Falha no envio."; + +/* Location: RoomServerError+UserFacingMessage.swift - Room server rejected the operation */ +"error.roomServer.permissionDenied" = "Permissão recusada."; + +/* Location: RoomServerError+UserFacingMessage.swift - Malformed device response during a room operation */ +"error.roomServer.invalidResponse" = "Resposta inválida do dispositivo."; + +/* Location: BinaryProtocolError+UserFacingMessage.swift - No active connection to the radio */ +"error.binaryProtocol.notConnected" = "Não ligado ao dispositivo."; + +/* Location: BinaryProtocolError+UserFacingMessage.swift - Binary request failed to send */ +"error.binaryProtocol.sendFailed" = "Falha ao enviar o pedido."; + +/* Location: BinaryProtocolError+UserFacingMessage.swift - Binary request timed out */ +"error.binaryProtocol.timeout" = "O pedido expirou."; + +/* Location: BinaryProtocolError+UserFacingMessage.swift - Malformed device response to a binary request */ +"error.binaryProtocol.invalidResponse" = "Resposta inválida do dispositivo."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Device row missing from the database */ +"error.persistence.deviceNotFound" = "Dispositivo não encontrado."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Contact row missing from the database */ +"error.persistence.contactNotFound" = "Contacto não encontrado."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Message row missing from the database */ +"error.persistence.messageNotFound" = "Mensagem não encontrada."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Channel row missing from the database */ +"error.persistence.channelNotFound" = "Canal não encontrado."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Remote node session row missing from the database */ +"error.persistence.remoteNodeSessionNotFound" = "Sessão do nodo remoto não encontrada."; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Database save failed - %@ is the failure reason */ +"error.persistence.saveFailed" = "Falha ao guardar: %@"; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Database fetch failed - %@ is the failure reason */ +"error.persistence.fetchFailed" = "Falha ao obter: %@"; + +/* Location: PersistenceStoreError+UserFacingMessage.swift - Stored data is malformed */ +"error.persistence.invalidData" = "Dados inválidos."; + +/* Location: SyncCoordinatorError+UserFacingMessage.swift - No active connection to the radio */ +"error.syncCoordinator.notConnected" = "Não ligado ao dispositivo."; + +/* Location: SyncCoordinatorError+UserFacingMessage.swift - Full sync failed - %@ is the failure reason */ +"error.syncCoordinator.syncFailed" = "Falha na sincronização: %@"; + +/* Location: SyncCoordinatorError+UserFacingMessage.swift - A sync is already running */ +"error.syncCoordinator.alreadySyncing" = "Já está a decorrer uma sincronização."; + +/* Location: DeviceServiceError+UserFacingMessage.swift - Device row missing from the database */ +"error.deviceService.deviceNotFound" = "Dispositivo não encontrado"; + +/* Location: DeviceServiceError+UserFacingMessage.swift - Saving device settings failed - %@ is the failure reason */ +"error.deviceService.persistenceFailed" = "Falha ao guardar as definições do dispositivo: %@"; + +/* Location: SettingsServiceError+UserFacingMessage.swift - No active connection to the radio */ +"error.settings.notConnected" = "Dispositivo não ligado"; + +/* Location: SettingsServiceError+UserFacingMessage.swift - Sending a settings command failed */ +"error.settings.sendFailed" = "Falha ao enviar o comando"; + +/* Location: SettingsServiceError+UserFacingMessage.swift - Malformed device response to a settings command */ +"error.settings.invalidResponse" = "Resposta inválida do dispositivo"; + +/* Location: SettingsServiceError+UserFacingMessage.swift - Setting write-back mismatch - %1$@ is the expected value, %2$@ is the value the device reports */ +"error.settings.verificationFailed" = "A definição não foi guardada. Esperado '%1$@', mas o dispositivo indica '%2$@'."; + +/* Location: SettingsServiceError+UserFacingMessage.swift - GPS write-back mismatch when GPS should be on */ +"error.settings.gpsNotSavedExpectedOn" = "A definição de GPS do dispositivo não foi guardada. Esperado 'Ativado', mas o dispositivo indica 'Desativado'."; + +/* Location: SettingsServiceError+UserFacingMessage.swift - GPS write-back mismatch when GPS should be off */ +"error.settings.gpsNotSavedExpectedOff" = "A definição de GPS do dispositivo não foi guardada. Esperado 'Desativado', mas o dispositivo indica 'Ativado'."; + +/* Location: KeychainError+UserFacingMessage.swift - Encoding a password for keychain storage failed */ +"error.keychain.encodingFailed" = "Falha ao codificar a palavra-passe"; + +/* Location: KeychainError+UserFacingMessage.swift - Keychain write failed - %lld is the OSStatus code */ +"error.keychain.storageFailed" = "Falha ao armazenar a palavra-passe (erro %lld)"; + +/* Location: KeychainError+UserFacingMessage.swift - Keychain read failed - %lld is the OSStatus code */ +"error.keychain.retrievalFailed" = "Falha ao recuperar a palavra-passe (erro %lld)"; + +/* Location: KeychainError+UserFacingMessage.swift - Keychain delete failed - %lld is the OSStatus code */ +"error.keychain.deletionFailed" = "Falha ao eliminar a palavra-passe (erro %lld)"; + +/* Location: KeyGenerationError+UserFacingMessage.swift - Vanity prefix search exhausted its attempt budget */ +"error.keyGeneration.maxAttemptsExceeded" = "Não foi possível gerar uma chave com esse prefixo. Tente outro."; + +/* Location: KeyGenerationError+UserFacingMessage.swift - Requested prefix byte is reserved by firmware */ +"error.keyGeneration.reservedPrefix" = "Esse prefixo está reservado e não pode ser usado."; + +/* Location: KeyGenerationError+UserFacingMessage.swift - System random number generator failed */ +"error.keyGeneration.randomGenerationFailed" = "Falha na geração segura de números aleatórios. Tente novamente."; + +/* Location: KeyGenerationError+UserFacingMessage.swift - Key data is not a valid Ed25519 expanded private key */ +"error.keyGeneration.invalidKey" = "A chave não é uma chave privada Ed25519 válida."; diff --git a/MC1/Resources/Localization/pt.lproj/Localizable.stringsdict b/MC1/Resources/Localization/pt.lproj/Localizable.stringsdict new file mode 100644 index 000000000..c7df8cfd5 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Localizable.stringsdict @@ -0,0 +1,29 @@ + + + + + + error.channelService.circuitBreakerOpen + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + one + Sincronização de canais suspensa após %lld falha consecutiva. + other + Sincronização de canais suspensa após %lld falhas consecutivas. + + + + diff --git a/MC1/Resources/Localization/pt.lproj/Map.strings b/MC1/Resources/Localization/pt.lproj/Map.strings new file mode 100644 index 000000000..15f679f49 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Map.strings @@ -0,0 +1,193 @@ +/* + Map.strings + MC1 + + European Portuguese (pt-PT) translation of strings for the Map feature. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Common + +/* Location: MapView.swift - Purpose: Done button for sheets */ +"map.common.done" = "OK"; + +// MARK: - Map Controls + +/* Location: MapView.swift - Purpose: Accessibility label when labels are hidden */ +"map.controls.showLabels" = "Mostrar etiquetas"; + +/* Location: MapView.swift - Purpose: Accessibility label for center on all contacts button */ +"map.controls.centerAll" = "Centrar em todos os contactos"; + +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for user location button */ +"map.controls.centerOnMyLocation" = "Centrar na minha localização"; + +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map options menu button */ +"map.controls.mapOptions" = "Opções do mapa"; + +/* Location: MapControlsToolbar.swift - Purpose: Map options menu toggle that locks the map bearing to north */ +"map.controls.lockNorth" = "Norte para cima"; + +/* Location: MapView.swift - Purpose: Accessibility label for refresh button */ +"map.controls.refresh" = "Atualizar contactos"; + +/* Location: MapControlsToolbar.swift - Purpose: Accessibility label for map filter control */ +"map.controls.filter" = "Filtrar"; + +/* Location: MapControlsToolbar.swift - Purpose: Accessibility value when filter differs from host defaults */ +"map.controls.filterActive" = "Filtros ativos"; + +// MARK: - Map Style Selection + +/* Location: MapStyleSelection.swift - Purpose: Standard map style option */ +"map.style.standard" = "Padrão"; + +/* Location: MapStyleSelection.swift - Purpose: Satellite map style option */ +"map.style.satellite" = "Satélite"; + +/* Location: MapStyleSelection.swift - Purpose: Topo map style option */ +"map.style.topo" = "Topografia"; + +/* Location: LayersMenu.swift - Purpose: Accessibility label for map style menu */ +"map.style.accessibilityLabel" = "Estilo do mapa"; + +/* Location: LayersMenu.swift - Purpose: Hint when style requires network */ +"map.style.requiresNetwork" = "Requer ligação de rede"; + +/* Location: LayersMenu.swift - Purpose: Hint when no offline pack covers viewport */ +"map.style.noOfflineCoverage" = "Nenhum mapa offline cobre esta área"; + +// MARK: - Contact Detail Sheet + +/* Location: MapView.swift ContactDetailSheet - Purpose: Section header for contact information */ +"map.detail.section.contactInfo" = "Informações do contacto"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for contact name */ +"map.detail.name" = "Nome"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for contact type */ +"map.detail.type" = "Tipo"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for favorite status */ +"map.detail.status" = "Estado"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Value showing contact is favorited */ +"map.detail.favorite" = "Favorito"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for last advertisement timestamp */ +"map.detail.lastAdvert" = "Último Advert"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Last heard (phone-clock on-air) label */ +"map.detail.lastHeard" = "Última receção"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for full public key identifier */ +"map.detail.publicKey" = "Chave pública"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Section header for location coordinates */ +"map.detail.section.location" = "Localização"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for latitude coordinate */ +"map.detail.latitude" = "Latitude"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for longitude coordinate */ +"map.detail.longitude" = "Longitude"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Section header for outbound path info */ +"map.detail.section.outboundPath" = "Caminho de saída"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for routing type */ +"map.detail.routing" = "Encaminhamento"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Flood routing type value */ +"map.detail.routingFlood" = "Flood"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Label for path length */ +"map.detail.pathLength" = "Comprimento do caminho"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Path length value with hop count */ +"map.detail.hops" = "%d saltos"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Path length value for single hop */ +"map.detail.hopSingular" = "1 salto"; + +// MARK: - Contact Detail Sheet Actions + +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to view repeater telemetry */ +"map.detail.action.telemetry" = "Telemetria"; + +/* Location: MapView.swift - Purpose: Saved History button for offline telemetry */ +/* Location: MapView.swift ContactDetailSheet - Purpose: Sheet title for telemetry authentication */ +"map.detail.action.telemetryAccessTitle" = "Acesso à telemetria"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to manage repeater */ +"map.detail.action.management" = "Gestão"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to join a room */ +"map.detail.action.joinRoom" = "Entrar na sala"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Button to send a message */ +"map.detail.action.sendMessage" = "Enviar mensagem"; + +// MARK: - Contact Type Names + +/* Location: MapView.swift ContactDetailSheet - Purpose: Display name for chat contact type */ +"map.nodeKind.chatContact" = "Contacto de chat"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Display name for repeater type */ +"map.nodeKind.repeater" = "Repetidor"; + +/* Location: MapView.swift ContactDetailSheet - Purpose: Display name for room type */ +"map.nodeKind.room" = "Sala"; + +// MARK: - Contact Callout + +/* Location: ContactCalloutContent.swift - Purpose: Button to view contact details */ +"map.callout.details" = "Detalhes"; + +/* Location: ContactCalloutContent.swift - Purpose: Button to send message from callout */ +"map.callout.message" = "Mensagem"; + +/* Location: ContactCalloutContent.swift - Purpose: Display name for contact in callout */ +"map.callout.nodeKind.contact" = "Contacto"; + +/* Location: ContactCalloutContent.swift - Purpose: Display name for repeater in callout */ +"map.callout.nodeKind.repeater" = "Repetidor"; + +/* Location: ContactCalloutContent.swift - Purpose: Display name for room in callout */ +"map.callout.nodeKind.room" = "Sala"; + +/* Location: DiscoveredNodeCalloutContent.swift - Purpose: Add discovered node from callout */ +"map.callout.add" = "Adicionar"; + +/* Location: DiscoveredNodeCalloutContent.swift - Purpose: Label that the pin is discovered, not on the node list */ +"map.callout.discovered" = "Descoberto"; + +/* Location: DiscoveredNodeDetailSheet.swift - Purpose: Navigation title for discovered node detail */ +"map.discoveredDetail.title" = "Nodo descoberto"; + +/* Location: DiscoveredNodeDetailSheet.swift - Purpose: Primary action to add discovered node to the radio table */ +"map.discoveredDetail.add" = "Adicionar aos nodos"; + +/* Location: DiscoveredNodeCalloutContent.swift - Purpose: Accessibility label for discovered pins */ +"map.pin.accessibility.discovered" = "Descoberto, não está na lista de nodos"; + +// MARK: - Contact Annotation + +/* Location: ContactAnnotation.swift - Purpose: Subtitle for favorite contacts */ +/* Location: ContactAnnotation.swift - Purpose: Subtitle for repeater nodes */ +/* Location: ContactAnnotation.swift - Purpose: Subtitle for room nodes */ +// MARK: - Offline Badge + +/* Label shown on map when device has no internet connection */ +"map.offlineBadge.label" = "Offline"; + +// MARK: - Chat Map Preview + +/* Location: MapPreviewFragmentView.swift - Purpose: Accessibility label for the chat map-location thumbnail */ +"map.preview.accessibilityLabel" = "Pré-visualização da localização no mapa"; + +/* Location: MapPreviewFragmentView.swift - Purpose: Accessibility hint for tapping the chat map-location thumbnail */ +"map.preview.accessibilityHint" = "Abre esta localização no mapa"; + +/* Location: MapPreviewFragmentView.swift - Purpose: Accessibility label for the retry control shown on the failed-thumbnail fallback */ +"map.preview.retryButton.accessibilityLabel" = "Tentar carregar miniatura do mapa novamente"; diff --git a/MC1/Resources/Localization/pt.lproj/Onboarding.strings b/MC1/Resources/Localization/pt.lproj/Onboarding.strings new file mode 100644 index 000000000..7271c9ba9 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Onboarding.strings @@ -0,0 +1,325 @@ +/* + Onboarding.strings + MC1 + + European Portuguese (pt-PT) translation of strings for the Onboarding feature. + AI-translated - please verify with native speakers. +*/ + + +// MARK: - Welcome View + +/* Location: WelcomeView.swift - App title displayed on welcome screen */ +"welcome.title" = "MeshCore One"; + +/* Location: WelcomeView.swift - Subtitle describing the app */ +"welcome.subtitle" = "Envie mensagens por uma rede construída pela comunidade, mesmo sem internet."; + +/* Location: WelcomeView.swift - Button to proceed to next onboarding step */ +"welcome.getStarted" = "Começar"; + + +// MARK: - Permissions View + +/* Location: PermissionsView.swift - Screen title for permissions */ +"permissions.title" = "Algumas permissões"; + +/* Location: PermissionsView.swift - Subtitle encouraging notification permission */ +"permissions.subtitle" = "As duas são opcionais. É possível mudar de ideia em Definições a qualquer momento."; + +/* Location: PermissionsView.swift - Permission card title for notifications */ +"permissions.notifications.title" = "Notificações"; + +/* Location: PermissionsView.swift - Permission card description for notifications */ +"permissions.notifications.description" = "Receba alertas de novas mensagens e bateria fraca, mesmo com o app fechado."; + +/* Location: PermissionsView.swift - Permission card title for location */ +"permissions.location.title" = "Localização"; + +/* Location: PermissionsView.swift - Permission card description for location */ +"permissions.location.description" = "Sugerir predefinições de rádio locais, mostrar a posição no mapa e ordenar contactos por distância."; + +/* Location: PermissionsView.swift - Button to proceed to next step */ +"permissions.continue" = "Continuar"; + +/* Location: PermissionsView.swift - Badge shown for optional permissions */ +"permissions.optional" = "Opcional"; + +/* Location: PermissionsView.swift - Button to open system settings */ +"permissions.openSettings" = "Definições"; + +/* Location: PermissionsView.swift - Button to begin a permission request */ +"permissions.request" = "Continuar"; + +/* Location: PermissionsView.swift - Alert title for location permission */ +"permissions.locationAlert.title" = "Permissão de localização"; + +/* Location: PermissionsView.swift - Alert button to open settings */ +"permissions.locationAlert.openSettings" = "Abrir Definições"; + +/* Location: PermissionsView.swift - Alert message explaining denied location permission */ +"permissions.locationAlert.message" = "A permissão de localização foi recusada anteriormente. Ative-a em Definições para partilhar a localização com os contactos da mesh."; + + +// MARK: - Device Scan View + +/* Location: DeviceScanView.swift - Screen title for device pairing */ +"deviceScan.title" = "Emparelhe o dispositivo"; + +/* Location: DeviceScanView.swift - Subtitle with pairing instructions */ +"deviceScan.subtitle" = "Ligue a alimentação do rádio, desligue-o de todas as outras apps e dispositivos e toque em Adicionar dispositivo."; + +/* Location: DeviceScanView.swift - Message shown when device is already paired */ +"deviceScan.alreadyPaired" = "O dispositivo já está emparelhado"; + +/* Location: DeviceScanView.swift - "I don't have a device yet" tertiary */ +"deviceScan.noDeviceYet" = "Ainda não tenho um dispositivo"; + +/* Location: DeviceScanView.swift - Button to continue after pairing */ +"deviceScan.continue" = "Continuar"; + +/* Location: DeviceScanView.swift - Button label while connecting */ +"deviceScan.connecting" = "A ligar..."; + +/* Location: DeviceScanView.swift - Button to connect simulator (debug) */ +"deviceScan.connectSimulator" = "Ligar simulador"; + +/* Location: DeviceScanView.swift - Button to continue in demo mode */ +"deviceScan.continueDemo" = "Continuar no modo demo"; + +/* Location: DeviceScanView.swift - Button to add a new device */ +"deviceScan.addDevice" = "Adicionar dispositivo"; + +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Tentar ligação novamente"; + +/* Location: DeviceScanView.swift - Button for troubleshooting */ +"deviceScan.deviceNotAppearing" = "Ajuda"; + +/* Location: DeviceScanView.swift - Button to connect via WiFi */ +"deviceScan.connectViaWifi" = "Ligar via Wi-Fi"; + +/* Location: DeviceScanView.swift - Alert title when demo mode is unlocked */ +"deviceScan.demoModeAlert.title" = "Modo demo desbloqueado"; + +/* Location: DeviceScanView.swift - Alert message for demo mode */ +"deviceScan.demoModeAlert.message" = "Agora é possível continuar sem um dispositivo. Ative ou desative o modo demo em Definições a qualquer momento."; + +/* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ +"deviceScan.error.authenticationFailed" = "O emparelhamento guardado com este rádio deixou de funcionar. Toque em Remover e tentar novamente para emparelhar de novo no app; o rádio pode mostrar um novo código de emparelhamento."; +"deviceScan.error.pinRejected" = "O PIN não foi aceite. Verifique o PIN mostrado no dispositivo e tente novamente. Primeiro, o iOS pede a confirmação da remoção do emparelhamento que falhou."; +"deviceScan.error.connectionFailed" = "Não foi possível ligar ao dispositivo. Tente novamente ou remova-o se o problema continuar."; + +// MARK: - Troubleshooting Sheet + +/* Location: DeviceScanView.swift - Navigation title for troubleshooting sheet */ +"troubleshooting.title" = "Resolução de problemas"; + +/* Location: DeviceScanView.swift - Section header for basic checks */ +"troubleshooting.basicChecks.header" = "Verificações básicas"; + +/* Location: DeviceScanView.swift - Check to ensure device is powered on */ +"troubleshooting.basicChecks.powerOn" = "Confirme que o dispositivo está ligado"; + +/* Location: DeviceScanView.swift - Check to move device closer */ +"troubleshooting.basicChecks.moveCloser" = "Aproxime o dispositivo do telemóvel"; + +/* Location: TroubleshootingSheet.swift - Check that radio isn't connected elsewhere */ +"troubleshooting.basicChecks.notConnectedElsewhere" = "Confirme que o rádio não está ligado a outro telemóvel ou app"; + +/* Location: DeviceScanView.swift - Check to restart the device */ +"troubleshooting.basicChecks.restart" = "Reinicie o dispositivo MeshCore"; + +/* Location: DeviceScanView.swift - Section header for factory reset help */ +"troubleshooting.factoryReset.header" = "Repor o dispositivo para os valores de fábrica?"; + +/* Location: DeviceScanView.swift - Explanation about stale pairings */ +"troubleshooting.factoryReset.explanation" = "Se o dispositivo MeshCore for reposto para os valores de fábrica, o iOS ainda pode ter o emparelhamento antigo. Limpar isto em Definições do sistema permite que o dispositivo volte a aparecer."; + +/* Location: DeviceScanView.swift - Additional explanation about removal confirmation */ +"troubleshooting.factoryReset.confirmationNote" = "Ao tocar abaixo, será necessário confirmar a remoção do emparelhamento antigo. Isto é normal — permite que o dispositivo reposto volte a aparecer."; + +/* Location: DeviceScanView.swift - Button to clear previous pairing */ +"troubleshooting.factoryReset.clearPairing" = "Limpar emparelhamento anterior"; + +/* Location: DeviceScanView.swift - Footer when no pairings found */ +"troubleshooting.factoryReset.noPairings" = "Nenhum emparelhamento anterior encontrado."; + +/* Location: DeviceScanView.swift - Footer showing pairing count - uses stringsdict */ +"troubleshooting.factoryReset.pairingsFound" = "%d emparelhamento(s) anterior(es) encontrado(s)."; + +/* Location: DeviceScanView.swift - Section header for system settings info */ +"troubleshooting.systemSettings.header" = "Definições do sistema"; + +/* Location: DeviceScanView.swift - Info about managing accessories */ +"troubleshooting.systemSettings.manageAccessories" = "Também é possível gerir acessórios Bluetooth em:"; + +/* Location: DeviceScanView.swift - Path to accessories in settings */ +"troubleshooting.systemSettings.path" = "Definições → Privacidade e segurança → Acessórios"; + +/* Location: TroubleshootingSheet.swift - Button that opens iOS Settings */ +"troubleshooting.systemSettings.openSettings" = "Abrir Definições"; + + +// MARK: - Still Not Appearing + +/* Location: TroubleshootingSheet.swift - Section header for last-resort flashing steps */ +"troubleshooting.stillNotAppearing.header" = "Ainda não aparece?"; + +/* Location: TroubleshootingSheet.swift - Body text explaining backup and reflash steps */ +"troubleshooting.stillNotAppearing.body" = "Se já tentou tudo acima, faça uma cópia de segurança da configuração do rádio no app MeshCore de Liam Cottle e depois apague o rádio e volte a gravar o firmware no computador em https://flasher.meshcore.io"; + + +// MARK: - Mesh Animation View + +/* Location: MeshAnimationView.swift - Accessibility label for mesh visualization */ +"meshAnimation.accessibilityLabel" = "Visualização da rede mesh"; + + +// MARK: - No Device Sheet + +/* Location: NoDeviceSheet.swift - Sheet title */ +"noDevice.sheet.title" = "Dê uma vista de olhos"; + +/* Location: NoDeviceSheet.swift - Sheet body */ +"noDevice.sheet.body" = "É necessário um rádio emparelhado para enviar e receber mensagens. Explore o app por agora e emparelhe a qualquer momento em Definições."; + +/* Location: NoDeviceSheet.swift - Primary CTA */ +"noDevice.sheet.confirm" = "Continuar"; + +/* Location: NoDeviceSheet.swift - Secondary CTA */ +"noDevice.sheet.cancel" = "Cancelar"; + + +// MARK: - Preset Step View + +/* Location: PresetStepView.swift - Title */ +"preset.title" = "Escolha uma predefinição"; + +/* Location: PresetStepView.swift - Subtitle when recommendation exists */ +"preset.subtitle.recommended" = "Recomendada para %@"; + +/* Location: PresetStepView.swift - Subtitle for empty-region fallback */ +"preset.subtitle.locale" = "Escolha uma predefinição para o rádio."; + +/* Location: PresetStepView.swift - Footer help line pointing users to the MeshCore Discord */ +"preset.discordHelp" = "Não sabe qual predefinição escolher? Entre no Discord oficial do MeshCore e pergunte! [https://meshcore.gg](https://meshcore.gg)"; + +/* Location: PresetStepView.swift - Apply CTA "Use %@" */ +"preset.use" = "Usar %@"; + +/* Location: PresetStepView.swift - Already-configured title */ +"preset.alreadyConfigured.title" = "Já configurado"; + +/* Location: PresetStepView.swift - Already-configured subtitle */ +"preset.alreadyConfigured.subtitle" = "O rádio já está em %@, a predefinição recomendada para %@."; + +/* Location: PresetStepView.swift - Already-configured primary CTA */ +"preset.alreadyConfigured.done" = "OK"; + +/* Location: PresetStepView.swift - Already-configured secondary link */ +"preset.alreadyConfigured.choose" = "Escolher outra predefinição"; + +/* Location: PresetStepView.apply(id:) - Error shown when user taps Apply before services finished wiring */ +"preset.error.notConnected" = "Ligue o dispositivo para aplicar esta predefinição."; + +/* Location: PresetStepView.swift - Generic Continue label used when no preset is selected */ +"preset.continue" = "Continuar"; + +/* Location: PresetStepView.swift - VoiceOver hint for tapping a preset card */ +"preset.row.accessibilityHint" = "Seleciona esta predefinição."; + +// MARK: - WiFi Connection Sheet + +/* Location: WiFiConnectionSheet.swift - Navigation title */ +"wifiConnection.title" = "Ligar via Wi-Fi"; + +/* Location: WiFiConnectionSheet.swift - Section header for connection details */ +"wifiConnection.connectionDetails.header" = "Detalhes da ligação"; + +/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ +"wifiConnection.ipAddress.placeholder" = "Endereço IP"; + +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Limpar endereço IP"; + +/* Location: WiFiConnectionSheet.swift - Placeholder for port field */ +"wifiConnection.port.placeholder" = "Porta"; + +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear port button */ +"wifiConnection.port.clearAccessibility" = "Limpar porta"; + +/* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ +"wifiConnection.connectionDetails.footer" = "Introduza o endereço de rede local do dispositivo MeshCore. A porta predefinida é 5000."; + +/* Location: WiFiConnectionSheet.swift - Button label while connecting */ +"wifiConnection.connecting" = "A ligar..."; + +/* Location: WiFiConnectionSheet.swift - Button to initiate connection */ +"wifiConnection.connect" = "Ligar"; + +/* Location: WiFiConnectionSheet.swift - Error message for invalid port */ +"wifiConnection.error.invalidPort" = "Número de porta inválido"; + + +// MARK: - Region Picker View + +/* Location: RegionPickerView.swift - Country picker label */ +"region.country" = "País"; + +/* Location: RegionPickerView.swift - State/Province picker label */ +"region.administrativeArea" = "Estado / Província"; + +/* Location: RegionPickerView.swift - Continue CTA in manual picker */ +"region.continue" = "Continuar"; + +/* Location: RegionStepView.swift - Title (both states) */ +"region.title" = "Escolha a região"; + +/* Location: RegionStepView.swift - Subtitle for the region step */ +"region.subtitle" = "Serão mostradas predefinições que funcionam na sua área."; + +/* Location: RegionStepView.swift - "Detected" tag */ +"region.detected.tag" = "Detetada"; + +/* Location: RegionStepView.swift - Caption "From your location" */ +"region.detected.source" = "Da sua localização"; + +/* Location: RegionStepView.swift - Primary CTA in detected state */ +"region.useThisRegion" = "Usar esta região"; + +/* Location: RegionStepView.swift - "Choose another" link */ +"region.chooseAnother" = "Escolher outra"; + +/* Location: RegionStepView.swift - "Use my location" link in manual state */ +"region.useMyLocation" = "Usar a minha localização"; + +/* Location: RegionStepView.swift - "Finding your region…" */ +"region.resolving" = "A procurar a região…"; + +/* Location: RegionStepView.swift - Error shown when user-initiated retry of "Use my location" cannot resolve a region */ +"region.useMyLocation.failure" = "A pesquisa de localização não devolveu uma região. Escolha manualmente abaixo."; + + +// MARK: - Device Scanner Sheet (macOS) + +/* Location: DeviceScannerSheet.swift - Navigation title for the macOS BLE scanner */ +"deviceScanner.title" = "Selecionar dispositivo"; + +/* Location: DeviceScannerSheet.swift - Placeholder name for peripherals with no advertised name */ +"deviceScanner.unknownDevice" = "Dispositivo desconhecido"; + +/* Location: DeviceScannerSheet.swift - Empty state shown while scanning for devices */ +"deviceScanner.scanning" = "A procurar dispositivos…"; + +/* Location: DeviceScannerSheet.swift - Title shown when Bluetooth is powered off on the macOS scanner */ +"deviceScanner.bluetoothOff.title" = "O Bluetooth está desligado"; + +/* Location: DeviceScannerSheet.swift - Guidance shown when Bluetooth is powered off on the macOS scanner */ +"deviceScanner.bluetoothOff.message" = "Ligue o Bluetooth para procurar dispositivos próximos."; + +/* Location: DeviceScannerSheet.swift - Title shown when the app is not authorized to use Bluetooth */ +"deviceScanner.bluetoothUnauthorized.title" = "Acesso ao Bluetooth necessário"; + +/* Location: DeviceScannerSheet.swift - Guidance shown when the app is not authorized to use Bluetooth */ +"deviceScanner.bluetoothUnauthorized.message" = "Permita o acesso ao Bluetooth em Definições para procurar dispositivos próximos."; diff --git a/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings new file mode 100644 index 000000000..42ba791c2 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings @@ -0,0 +1,913 @@ +/* + RemoteNodes.strings + MC1 + + European Portuguese (pt-PT) translation of remote nodes strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Node Authentication Sheet + +/* Location: NodeAuthenticationSheet.swift - Navigation title for room authentication */ +"remoteNodes.auth.joinRoom" = "Entrar na sala"; + +/* Location: NodeAuthenticationSheet.swift - Navigation title for repeater management */ +"remoteNodes.auth.management" = "Gestão"; + +/* Location: NodeAuthenticationSheet.swift - Cancel button */ +"remoteNodes.auth.cancel" = "Cancelar"; + +/* Location: NodeAuthenticationSheet.swift - Node details section header */ +"remoteNodes.auth.nodeDetails" = "Detalhes do nodo"; + +/* Location: NodeAuthenticationSheet.swift - Name label */ +"remoteNodes.auth.name" = "Nome"; + +/* Location: NodeAuthenticationSheet.swift - Type label */ +"remoteNodes.auth.type" = "Tipo"; + +/* Location: NodeAuthenticationSheet.swift - Room type value */ +"remoteNodes.auth.typeRoom" = "Sala"; + +/* Location: NodeAuthenticationSheet.swift - Repeater type value */ +"remoteNodes.auth.typeRepeater" = "Repetidor"; + +/* Location: NodeAuthenticationSheet.swift - Authentication section header */ +"remoteNodes.auth.authentication" = "Autenticação"; + +/* Location: NodeAuthenticationSheet.swift - Password field placeholder */ +"remoteNodes.auth.password" = "Palavra-passe"; + +/* Location: NodeAuthenticationSheet.swift - Remember password toggle */ +"remoteNodes.auth.rememberPassword" = "Memorizar palavra-passe"; + +/* Location: NodeAuthenticationSheet.swift - Error accessibility label prefix */ +"remoteNodes.auth.errorPrefix" = "Erro: %@"; + +/* Location: NodeAuthenticationSheet.swift - Password too long warning for repeaters */ +"remoteNodes.auth.passwordTooLongRepeaters" = "Os repetidores MeshCore só aceitam palavras-passe com até %d caracteres. Os caracteres extra serão ignorados."; + +/* Location: NodeAuthenticationSheet.swift - Password too long warning for rooms */ +"remoteNodes.auth.passwordTooLongRooms" = "As salas MeshCore só aceitam palavras-passe com até %d caracteres. Os caracteres extra serão ignorados."; + +/* Location: NodeAuthenticationSheet.swift - Countdown text showing seconds remaining */ +"remoteNodes.auth.secondsRemaining" = "Até %d segundos restantes"; + +/* Location: NodeAuthenticationSheet.swift - Connect button */ +"remoteNodes.auth.connect" = "Ligar"; + +/* Location: NodeAuthenticationSheet.swift - Path section header */ +"remoteNodes.auth.path" = "Caminho"; + +/* Location: NodeAuthenticationSheet.swift - Flood routing toggle */ +"remoteNodes.auth.floodRouting" = "Encaminhamento Flood"; +"remoteNodes.auth.floodRetryStatus" = "Sem resposta pelo caminho conhecido. A tentar novamente com encaminhamento flood."; +"remoteNodes.auth.floodFooter" = "O encaminhamento flood é mais lento e usa mais tempo no ar da rede. Utilize-o quando o caminho conhecido não funcionar a partir da localização atual. Um novo caminho é aprendido automaticamente após iniciar sessão."; + +/* Location: NodeAuthenticationSheet.swift - Path section footer when stored path exists */ +"remoteNodes.auth.pathFooter" = "Utilize o caminho conhecido até este nodo, ou mude para o encaminhamento flood para que a rede encontre um caminho."; + +/* Location: NodeAuthenticationSheet.swift - Path hop whose repeater is not known */ +"remoteNodes.auth.pathHopUnknown" = ""; + +/* Location: NodeAuthenticationSheet.swift - Label when no route is set */ +"remoteNodes.auth.noRouteSet" = "Nenhum caminho definido"; + +/* Location: NodeAuthenticationSheet.swift - Path section footer when no route is set */ +"remoteNodes.auth.noRouteFooter" = "Nenhum caminho conhecido até este nodo. A rede irá encontrar um caminho automaticamente."; + +/* Location: NodeAuthenticationSheet.swift - Accessibility announcement for countdown */ +"remoteNodes.auth.secondsRemainingAnnouncement" = "%d segundos restantes"; + +// MARK: - Repeater Settings + +/* Location: RepeaterSettingsView.swift - Navigation title */ +"remoteNodes.settings.title" = "Definições do repetidor"; + +/* Location: RepeaterSettingsView.swift - Done button (used in multiple places) */ +"remoteNodes.settings.done" = "OK"; + +/* Location: NodeSettingsView.swift - Settings tab label */ +"remoteNodes.settings.tab.settings" = "Definições"; + +/* Location: NodeSettingsView.swift - CLI tab label */ +"remoteNodes.settings.tab.cli" = "CLI"; + +/* Location: NodeSettingsView.swift - Telemetry tab label */ +"remoteNodes.settings.tab.telemetry" = "Telemetria"; + +/* Location: NodeSettingsView.swift - View mode picker label */ +"remoteNodes.settings.tab.picker" = "Modo de visualização"; + +/* Location: RepeaterSettingsView.swift - Success alert title */ +"remoteNodes.settings.success" = "Sucesso"; + +/* Location: RepeaterSettingsView.swift - OK button */ +"remoteNodes.settings.ok" = "OK"; + +/* Location: RepeaterSettingsView.swift - Default success message */ +"remoteNodes.settings.settingsApplied" = "Definições aplicadas"; + +/* Location: RepeaterSettingsView.swift - Device info section title */ +"remoteNodes.settings.deviceInfo" = "Informações do dispositivo"; + +/* Location: RepeaterSettingsView.swift - Firmware label */ +"remoteNodes.settings.firmware" = "Firmware"; + +/* Location: RepeaterSettingsView.swift - Device time label */ +"remoteNodes.settings.deviceTime" = "Hora do dispositivo"; + +/* Location: RepeaterSettingsView.swift - Radio parameters section title */ +"remoteNodes.settings.radioParameters" = "Parâmetros de rádio"; + +/* Location: RepeaterSettingsView.swift - Radio restart warning */ +"remoteNodes.settings.radioRestartWarning" = "Estas alterações só terão efeito depois de o repetidor ser reiniciado"; + +/* Location: RepeaterSettingsView.swift - Frequency label */ +"remoteNodes.settings.frequencyMHz" = "Frequência (MHz)"; + +/* Location: RepeaterSettingsView.swift - MHz placeholder */ +"remoteNodes.settings.mhz" = "MHz"; + +/* Location: RepeaterSettingsView.swift - Loading placeholder */ +"remoteNodes.settings.loading" = "A carregar..."; + +/* Location: RepeaterSettingsView.swift - Failed to load placeholder */ +"remoteNodes.settings.failedToLoad" = "Falha ao carregar"; + +/* Location: RepeaterSettingsView.swift - Bandwidth label */ +"remoteNodes.settings.bandwidthKHz" = "Largura de banda (kHz)"; + +/* Location: RepeaterSettingsView.swift - Accessibility label for bandwidth picker options - %@ is formatted bandwidth */ +"remoteNodes.settings.accessibility.bandwidthLabel" = "%@ quilohertz"; + +/* Location: RepeaterSettingsView.swift - Bandwidth accessibility hint */ +"remoteNodes.settings.bandwidthHint" = "Valores mais baixos aumentam o alcance, mas diminuem a velocidade"; + +/* Location: RepeaterSettingsView.swift - Spreading factor label */ +"remoteNodes.settings.spreadingFactor" = "Fator de espalhamento"; + +/* Location: RepeaterSettingsView.swift - Accessibility label for spreading factor picker options - %d is the factor */ +"remoteNodes.settings.accessibility.spreadingFactorLabel" = "Fator de espalhamento %d"; + +/* Location: RepeaterSettingsView.swift - Spreading factor accessibility hint */ +"remoteNodes.settings.spreadingFactorHint" = "Valores mais altos aumentam o alcance, mas diminuem a velocidade"; + +/* Location: RepeaterSettingsView.swift - Coding rate label */ +"remoteNodes.settings.codingRate" = "Taxa de codificação"; + +/* Location: RepeaterSettingsView.swift - Accessibility label for coding rate picker options - %d is the rate */ +"remoteNodes.settings.accessibility.codingRateLabel" = "Taxa de codificação %d"; + +/* Location: RepeaterSettingsView.swift - Coding rate accessibility hint */ +"remoteNodes.settings.codingRateHint" = "Valores mais altos adicionam correção de erros, mas diminuem a velocidade"; + +/* Location: RepeaterSettingsView.swift - TX power label */ +"remoteNodes.settings.txPowerDbm" = "Potência TX (dBm)"; + +/* Location: RepeaterSettingsView.swift - dBm placeholder */ +"remoteNodes.settings.dbm" = "dBm"; + +/* Location: RepeaterSettingsView.swift - Apply radio settings button */ +"remoteNodes.settings.applyRadioSettings" = "Aplicar definições de rádio"; + +/* Location: RepeaterSettingsView.swift - Identity & location section title */ +"remoteNodes.settings.identityLocation" = "Identidade e localização"; + +/* Location: RepeaterSettingsView.swift - Latitude label */ +"remoteNodes.settings.latitude" = "Latitude"; + +/* Location: RepeaterSettingsView.swift - Lat placeholder */ +/* Location: RepeaterSettingsView.swift - Longitude label */ +"remoteNodes.settings.longitude" = "Longitude"; + +/* Location: RepeaterSettingsView.swift - Lon placeholder */ +/* Location: RepeaterSettingsView.swift - Pick on map button */ +"remoteNodes.settings.pickOnMap" = "Escolher no mapa"; + +/* Location: RepeaterSettingsView.swift - Apply identity settings button */ +"remoteNodes.settings.applyIdentitySettings" = "Aplicar definições de identidade"; + +/* Location: RepeaterSettingsView.swift - Contact info section title */ +"remoteNodes.settings.contactInfo" = "Informações de contacto"; + +/* Location: RepeaterSettingsView.swift - Contact info placeholder */ +"remoteNodes.settings.contactInfoPlaceholder" = "Frequência, nome do operador, sítio web..."; + +/* Location: RepeaterSettingsView.swift - Contact info footer */ +"remoteNodes.settings.contactInfoFooter" = "Dados de contacto públicos visíveis para outros nodos. Utilize quebras de linha para separar os campos."; + +/* Location: RepeaterSettingsView.swift - Apply contact info button */ +"remoteNodes.settings.applyContactInfo" = "Aplicar informações de contacto"; + +/* Location: RepeaterSettingsView.swift - Contact info character count format */ +/* Location: RepeaterSettingsView.swift - Behavior section title */ +"remoteNodes.settings.behavior" = "Comportamento"; + +/* Location: RepeaterSettingsView.swift - Repeater mode toggle */ +"remoteNodes.settings.repeaterMode" = "Modo repetidor"; + +/* Location: RepeaterSettingsView.swift - Advert interval (0-hop) label */ +"remoteNodes.settings.advertInterval0Hop" = "Intervalo de Advert (0-hop)"; + +/* Location: RepeaterSettingsView.swift - Minutes unit */ +"remoteNodes.settings.min" = "min"; + +/* Location: RepeaterSettingsView.swift - Advert interval (flood) label */ +"remoteNodes.settings.advertIntervalFlood" = "Intervalo de Advert (flood)"; + +/* Location: RepeaterSettingsView.swift - Hours unit */ +"remoteNodes.settings.hrs" = "h"; + +/* Location: RepeaterSettingsView.swift - Max flood hops label */ +"remoteNodes.settings.maxFloodHops" = "Máximo de saltos Flood"; + +/* Location: RepeaterSettingsView.swift - Hops unit */ +"remoteNodes.settings.hops" = "saltos"; + +/* Location: RepeaterSettingsView.swift - Apply behavior settings button */ +"remoteNodes.settings.applyBehaviorSettings" = "Aplicar definições de comportamento"; + +/* Location: RepeaterSettingsView.swift - Security section title */ +"remoteNodes.settings.security" = "Segurança"; + +/* Location: RepeaterSettingsView.swift - New password placeholder */ +"remoteNodes.settings.newPassword" = "Nova palavra-passe"; + +/* Location: RepeaterSettingsView.swift - Confirm password placeholder */ +"remoteNodes.settings.confirmPassword" = "Confirmar palavra-passe"; + +/* Location: RepeaterSettingsView.swift - Change password button */ +"remoteNodes.settings.changePassword" = "Alterar palavra-passe"; + +/* Location: RepeaterSettingsView.swift - Security footer text */ +"remoteNodes.settings.securityFooter" = "Altere a palavra-passe de autenticação de administrador."; + +/* Location: RepeaterSettingsView.swift - Device info section footer */ +"remoteNodes.settings.deviceInfoFooter" = "Versão do firmware e relógio do dispositivo."; + +/* Location: RepeaterSettingsView.swift - Radio settings section footer */ +"remoteNodes.settings.radioFooter" = "Frequência, largura de banda, fator de espalhamento e taxa de codificação."; + +/* Location: RepeaterSettingsView.swift - Identity section footer */ +"remoteNodes.settings.identityFooter" = "Nome do repetidor e coordenadas GPS para apresentação no mapa."; + +/* Location: RepeaterSettingsView.swift - Behavior section footer */ +"remoteNodes.settings.behaviorFooter" = "Intervalos de Advert, saltos flood e modo repetidor."; + +/* Location: RepeaterSettingsView.swift - Device actions section header */ +"remoteNodes.settings.deviceActions" = "Ações do dispositivo"; + +/* Location: RepeaterSettingsView.swift - Send advert button */ +"remoteNodes.settings.sendAdvert" = "Enviar Advert"; + +/* Location: RepeaterSettingsView.swift - Sync time button */ +"remoteNodes.settings.syncTime" = "Sincronizar hora"; + +/* Location: RepeaterSettingsView.swift - Reboot device button */ +"remoteNodes.settings.rebootDevice" = "Reiniciar dispositivo"; + +/* Location: RepeaterSettingsView.swift - Reboot confirmation dialog title */ +"remoteNodes.settings.rebootConfirmTitle" = "Reiniciar repetidor?"; + +/* Location: RepeaterSettingsView.swift - Reboot confirmation dialog button */ +"remoteNodes.settings.reboot" = "Reiniciar"; + +/* Location: RepeaterSettingsView.swift - Reboot confirmation message */ +"remoteNodes.settings.rebootMessage" = "O repetidor irá reiniciar e ficará temporariamente indisponível."; + +// MARK: - Repeater Settings ViewModel Messages + +/* Location: RepeaterSettingsViewModel.swift - Radio not loaded error */ +"remoteNodes.settings.radioNotLoaded" = "Definições de rádio não carregadas"; + +/* Location: RepeaterSettingsViewModel.swift - Radio applied success */ +"remoteNodes.settings.radioAppliedSuccess" = "Definições de rádio aplicadas. Reinicie o dispositivo para que tenham efeito."; + +/* Location: RepeaterSettingsViewModel.swift - Radio apply failure */ +"remoteNodes.settings.radioApplyFailed" = "Não foi possível aplicar as definições de rádio"; + +/* Location: RepeaterSettingsViewModel.swift - General apply failure */ +"remoteNodes.settings.someSettingsFailedToApply" = "Não foi possível aplicar algumas definições"; + +/* Location: RepeaterSettingsViewModel.swift - Empty password error */ +"remoteNodes.settings.passwordEmpty" = "A palavra-passe não pode estar vazia"; + +/* Location: RepeaterSettingsViewModel.swift - Password mismatch error */ +"remoteNodes.settings.passwordMismatch" = "As palavras-passe não coincidem"; + +/* Location: RepeaterSettingsViewModel.swift - Password changed success */ +/* Location: RepeaterSettingsViewModel.swift - Password change failure */ +"remoteNodes.settings.passwordChangeFailed" = "Não foi possível alterar a palavra-passe"; + +/* Location: RepeaterSettingsViewModel.swift - Reboot sent success */ +"remoteNodes.settings.rebootSent" = "Comando de reinício enviado"; + +/* Location: RepeaterSettingsViewModel.swift - Advert sent success */ +"remoteNodes.settings.advertSent" = "Advert enviado"; + +/* Location: RepeaterSettingsViewModel.swift - Time synced success */ +"remoteNodes.settings.timeSynced" = "Hora sincronizada"; + +/* Location: RepeaterSettingsViewModel.swift - Clock ahead error */ +"remoteNodes.settings.clockAheadError" = "O relógio do repetidor está adiantado em relação à hora do telemóvel. Se estiver demasiado adiantado, reinicie o repetidor e sincronize a hora novamente."; + +/* Location: RepeaterSettingsViewModel.swift - Sync time failure */ +"remoteNodes.settings.syncTimeFailed" = "Não foi possível sincronizar a hora"; + +/* Location: RepeaterSettingsViewModel.swift - Unexpected response error */ +"remoteNodes.settings.unexpectedResponse" = "Resposta inesperada: %@"; + +/* Location: RepeaterSettingsViewModel.swift - Not connected error */ +/* Location: RepeaterSettingsViewModel.swift - Timeout error */ +/* Location: NodeSettingsViewModel.swift - No service error */ +"remoteNodes.settings.noService" = "Serviço indisponível"; + +/* Location: RepeaterSettingsViewModel.swift - Advert interval validation error */ +"remoteNodes.settings.advertIntervalValidation" = "Aceita 0 (desativado) ou 60-240 min"; + +/* Location: RepeaterSettingsViewModel.swift - Flood interval validation error */ +"remoteNodes.settings.floodIntervalValidation" = "Aceita 0 (desligado) ou 3-168 horas"; + +/* Location: RepeaterSettingsViewModel.swift - Flood max hops validation error */ +"remoteNodes.settings.floodMaxValidation" = "Aceita 0-64 saltos"; + +/* Location: NodeSettingsViewModel.swift - Node name length validation error */ +"remoteNodes.settings.nameValidation" = "Aceita até %d bytes"; + +/* Location: NodeSettingsViewModel.swift - Latitude range validation error */ +"remoteNodes.settings.latitudeValidation" = "Aceita -90 a 90"; + +/* Location: NodeSettingsViewModel.swift - Longitude range validation error */ +"remoteNodes.settings.longitudeValidation" = "Aceita -180 a 180"; + +// MARK: - Repeater Settings: Regions + +/* Location: RepeaterSettingsView.swift - Regions section title */ +"remoteNodes.settings.regions" = "Regiões"; + +/* Location: RepeaterSettingsView.swift - Regions section footer */ +"remoteNodes.settings.regionsFooter" = "Guarde no repetidor para manter as alterações após os reinícios."; + +/* Location: RepeaterSettingsView.swift - Unscoped region display name */ +"remoteNodes.settings.regions.allTraffic" = "Sem âmbito"; + +/* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Sem âmbito)"; + +/* Location: RepeaterSettingsView.swift - Home region picker label */ +"remoteNodes.settings.regions.homeRegion" = "Região home"; + +/* Location: RepeaterSettingsView.swift - No home region set */ +/* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ +/* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ +"remoteNodes.settings.regions.floodToggleHint" = "Quando desligado, os pacotes flood desta região são descartados"; + +/* Location: RepeaterSettingsView.swift - Add region button */ +"remoteNodes.settings.regions.addRegion" = "Adicionar região"; + +/* Location: RepeaterSettingsView.swift - Add region alert title */ +"remoteNodes.settings.regions.addRegionTitle" = "Adicionar região"; + +/* Location: RepeaterSettingsView.swift - Region name placeholder */ +"remoteNodes.settings.regions.regionName" = "Nome da região"; + +/* Location: RepeaterSettingsView.swift - Save regions to device button */ +"remoteNodes.settings.regions.saveToDevice" = "Guardar no repetidor"; + +/* Location: RepeaterSettingsViewModel.swift - Region save success */ +/* Location: RepeaterSettingsViewModel.swift - Region save failure */ +"remoteNodes.settings.regions.saveFailed" = "Não foi possível guardar as regiões"; + +/* Location: RepeaterSettingsViewModel.swift - Region not found error */ +"remoteNodes.settings.regions.unknownRegion" = "Região desconhecida"; + +/* Location: RepeaterSettingsViewModel.swift - Region has children error */ +"remoteNodes.settings.regions.notEmpty" = "Remova primeiro as regiões filhas"; + +/* Location: RepeaterSettingsViewModel.swift - Region add failure */ +"remoteNodes.settings.regions.addFailed" = "Não foi possível adicionar a região"; + +/* Location: RepeaterSettingsView.swift - Region name charset validation */ +"remoteNodes.settings.regions.invalidName" = "Os nomes de região só podem conter letras, números e hífenes."; + +/* Location: RepeaterSettingsView.swift - Region name length validation */ +"remoteNodes.settings.regions.nameTooLong" = "Os nomes de região estão limitados a %d bytes."; + +/* Location: RepeaterSettingsView.swift - Duplicate region name validation */ +"remoteNodes.settings.regions.duplicate" = "Esta região já existe."; + +/* Location: RepeaterSettingsViewModel.swift - Region remove failure */ +"remoteNodes.settings.regions.removeFailed" = "Não foi possível remover a região"; + +/* Location: RepeaterSettingsViewModel.swift - No regions on device */ +"remoteNodes.settings.regions.empty" = "Nenhuma região configurada"; + +// MARK: - Repeater Status + +/* Location: RepeaterStatusView.swift - Navigation title */ +"remoteNodes.status.title" = "Estado do repetidor"; + +/* Location: RepeaterStatusView.swift - Guest mode badge in header */ +"remoteNodes.status.guestMode" = "Modo convidado"; +"remoteNodes.status.clockAhead" = "O relógio deste nodo está %@ adiantado em relação ao seu rádio. Um nodo com o relógio impreciso pode ignorar comandos ou mostrar horas de mensagem erradas."; +"remoteNodes.status.clockBehind" = "O relógio deste nodo está %@ atrasado em relação ao seu rádio. Um nodo com o relógio impreciso pode ignorar comandos ou mostrar horas de mensagem erradas."; + +/* Location: RepeaterStatusView.swift - Owner info section label */ +"remoteNodes.status.ownerInfo" = "Informações de contacto"; + +/* Location: RepeaterStatusView.swift - No owner info empty state */ +"remoteNodes.status.noOwnerInfo" = "Sem informações de contacto"; + +/* Location: RepeaterStatusView.swift - Owner info section footer */ +"remoteNodes.status.ownerInfoFooter" = "Informações de contacto e versão do firmware."; + +/* Location: RepeaterStatusView.swift - Status section header */ +"remoteNodes.status.statusSection" = "Estado"; + +/* Location: RepeaterStatusView.swift - Battery label */ +"remoteNodes.status.battery" = "Bateria"; + +/* Location: RepeaterStatusView.swift - Uptime label */ +"remoteNodes.status.uptime" = "Tempo de atividade"; + +/* Location: SharedNodeViews.swift - Airtime label */ +"remoteNodes.status.airtime" = "Tempo no ar"; + +/* Location: SharedNodeViews.swift - Airtime percent label */ +"remoteNodes.status.airtimePercent" = "Tempo no ar %%"; + +/* Location: RepeaterStatusView.swift - Last RSSI label */ +"remoteNodes.status.lastRssi" = "Último RSSI"; + +/* Location: RepeaterStatusView.swift - Last SNR label */ +"remoteNodes.status.lastSnr" = "Último SNR"; + +/* Location: RepeaterStatusView.swift - Noise floor label */ +"remoteNodes.status.noiseFloor" = "Ruído de fundo"; + +/* Location: SharedNodeStatusViews.swift - Section header grouping the packet-count rows */ +"remoteNodes.status.packets" = "Pacotes"; +/* Location: SharedNodeStatusViews.swift - Total sent packets label, under the Packets group header */ +"remoteNodes.status.packetsSent" = "Enviados"; + +/* Location: SharedNodeStatusViews.swift - Total received packets label, under the Packets group header */ +"remoteNodes.status.packetsReceived" = "Recebidos"; + +/* Location: SharedNodeStatusViews.swift - Receive errors label, under the Packets group header */ +"remoteNodes.status.receiveErrors" = "Erros"; +/* Location: SharedNodeStatusViews.swift - Sent (direct) packets label */ +"remoteNodes.status.sentDirect" = "Enviados (direto)"; +/* Location: SharedNodeStatusViews.swift - Sent (flood) packets label */ +"remoteNodes.status.sentFlood" = "Enviados (Flood)"; +/* Location: SharedNodeStatusViews.swift - Received (direct) packets label */ +"remoteNodes.status.receivedDirect" = "Recebidos (direto)"; +/* Location: SharedNodeStatusViews.swift - Received (flood) packets label */ +"remoteNodes.status.receivedFlood" = "Recebidos (Flood)"; +/* Location: SharedNodeStatusViews.swift - Duplicate packets label */ +"remoteNodes.status.duplicates" = "Duplicados"; + +/* Location: RepeaterStatusView.swift - Neighbors section label */ +"remoteNodes.status.neighbors" = "Vizinhos"; + +/* Location: RepeaterStatusView.swift - No neighbors empty state */ +"remoteNodes.status.noNeighbors" = "Nenhum vizinho descoberto"; + +/* Location: RepeaterStatusView.swift - Battery curve section label */ +"remoteNodes.status.batteryCurve" = "Curva da bateria"; + +/* Location: RepeaterStatusView.swift - Telemetry section label */ +"remoteNodes.status.telemetry" = "Telemetria"; + +/* Location: RepeaterStatusView.swift - No sensor data empty state */ +"remoteNodes.status.noSensorData" = "Sem dados de sensores"; + +/* Location: RepeaterStatusView.swift - No telemetry data empty state */ +"remoteNodes.status.noTelemetryData" = "Sem dados de telemetria"; + +/* Location: RepeaterStatusView.swift - Unknown neighbor name */ +"remoteNodes.status.unknown" = "Desconhecido"; + +/* Location: RepeaterStatusView.swift - Seconds ago format */ +"remoteNodes.status.secondsAgo" = "há %ds"; + +/* Location: RepeaterStatusView.swift - Minutes ago format */ +"remoteNodes.status.minutesAgo" = "há %dm"; + +/* Location: RepeaterStatusView.swift - Hours ago format */ +"remoteNodes.status.hoursAgo" = "há %dh"; + +/* Location: RepeaterStatusView.swift - SNR display format */ +"remoteNodes.status.snrFormat" = "SNR %@dB"; + +/* Location: RepeaterStatusView.swift - Accessibility label for possible match indicator */ +"remoteNodes.status.possibleMatch" = "Possível correspondência, associada pelo prefixo curto"; + +/* Location: RepeaterStatusView.swift - Title for possible match explanation popover */ +"remoteNodes.status.possibleMatchTitle" = "Possível correspondência"; + +/* Location: RepeaterStatusView.swift - Explanation of what a possible match means */ +"remoteNodes.status.possibleMatchExplanation" = "Vários nodos partilham este prefixo. O nome apresentado pode não estar correto."; + +// MARK: - Repeater Status ViewModel Messages + +/* Location: RepeaterStatusViewModel.swift - Request timed out */ +"remoteNodes.status.requestTimedOut" = "O pedido expirou"; +"remoteNodes.status.telemetryTimedOut" = "Tempo esgotado ou o contacto tem a telemetria desativada"; + +/* Location: RepeaterStatusViewModel.swift - Uptime 1 day format */ +"remoteNodes.status.uptime1Day" = "1 dia %dh %dm"; + +/* Location: RepeaterStatusViewModel.swift - Uptime multiple days format */ +"remoteNodes.status.uptimeDays" = "%d dias %dh %dm"; + +/* Location: RepeaterStatusViewModel.swift - Uptime hours format */ +"remoteNodes.status.uptimeHours" = "%dh %dm"; + +/* Location: RepeaterStatusViewModel.swift - Uptime minutes format */ +"remoteNodes.status.uptimeMinutes" = "%dm"; + +/* Location: RepeaterStatusViewModel.swift - Failed to load OCV settings */ +"remoteNodes.status.ocvLoadFailed" = "Não foi possível carregar as definições da curva da bateria"; + +/* Location: RepeaterStatusViewModel.swift - Cannot save OCV error */ +"remoteNodes.status.ocvSaveNoContact" = "Não é possível guardar: contacto não encontrado"; + +/* Location: RepeaterStatusViewModel.swift - OCV save failed */ +"remoteNodes.status.ocvSaveFailed" = "Não foi possível guardar: %@"; + +/* Location: RepeaterStatusView.swift - Channel header for grouped telemetry */ +"remoteNodes.status.channel" = "Canal %d"; + +/* Location: RepeaterStatusView.swift - Telemetry section footer */ +"remoteNodes.status.telemetryFooter" = "Leituras de sensores como temperatura, humidade e tensão."; + +/* Location: SharedNodeStatusViews.swift - Telemetry sensor type display labels */ +"remoteNodes.status.sensor.digitalInput" = "Entrada digital"; +"remoteNodes.status.sensor.digitalOutput" = "Saída digital"; +"remoteNodes.status.sensor.analogInput" = "Entrada analógica"; +"remoteNodes.status.sensor.analogOutput" = "Saída analógica"; +"remoteNodes.status.sensor.genericSensor" = "Sensor"; +"remoteNodes.status.sensor.illuminance" = "Iluminância"; +"remoteNodes.status.sensor.presence" = "Presença"; +"remoteNodes.status.sensor.temperature" = "Temperatura"; +"remoteNodes.status.sensor.humidity" = "Humidade"; +"remoteNodes.status.sensor.accelerometer" = "Acelerómetro"; +"remoteNodes.status.sensor.barometer" = "Pressão"; +"remoteNodes.status.sensor.voltage" = "Tensão"; +"remoteNodes.status.sensor.current" = "Corrente"; +"remoteNodes.status.sensor.frequency" = "Frequência"; +"remoteNodes.status.sensor.percentage" = "Percentagem"; +"remoteNodes.status.sensor.altitude" = "Altitude"; +"remoteNodes.status.sensor.load" = "Carga"; +"remoteNodes.status.sensor.concentration" = "Concentração"; +"remoteNodes.status.sensor.power" = "Potência"; +"remoteNodes.status.sensor.distance" = "Distância"; +"remoteNodes.status.sensor.energy" = "Energia"; +"remoteNodes.status.sensor.direction" = "Direção"; +"remoteNodes.status.sensor.unixTime" = "Hora"; +"remoteNodes.status.sensor.gyrometer" = "Girómetro"; +"remoteNodes.status.sensor.colour" = "Cor"; +"remoteNodes.status.sensor.gps" = "GPS"; +"remoteNodes.status.sensor.switchValue" = "Interruptor"; + +/* Location: RepeaterStatusView.swift - Neighbors section footer */ +"remoteNodes.status.neighborsFooter" = "Outros nodos descobertos por este repetidor e a qualidade do respetivo sinal."; + +/* Location: RepeaterStatusView.swift - Discover neighbours button label */ +"remoteNodes.status.discoverNeighbors" = "Descobrir vizinhos"; + +/* Location: RepeaterStatusView.swift - Discovery in progress with countdown */ +"remoteNodes.status.discoveringSeconds" = "A descobrir... %ds"; + +/* Location: RepeaterStatusView.swift - Battery curve section footer */ +"remoteNodes.status.batteryCurveFooter" = "Mapeamento de tensão para percentagem usado para estimar o nível da bateria."; + +/* Location: NodeTelemetryView.swift - Refresh button accessibility label */ +"remoteNodes.status.refresh" = "Atualizar"; + +/* Location: SharedNodeStatusViews.swift - Per-section reload button accessibility label for the status counters */ +"remoteNodes.status.accessibility.reloadStatus" = "Recarregar estado"; + +/* Location: SharedNodeStatusViews.swift - Per-section reload button accessibility label for telemetry */ +"remoteNodes.status.accessibility.reloadTelemetry" = "Recarregar telemetria"; + +/* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for owner info */ +"remoteNodes.status.accessibility.reloadOwnerInfo" = "Recarregar informações de contacto"; + +/* Location: RepeaterStatusContent.swift - Per-section reload button accessibility label for neighbors */ +"remoteNodes.status.accessibility.reloadNeighbors" = "Recarregar vizinhos"; + +// MARK: - Neighbors Map + +/* Location: RepeaterStatusContent.swift - View on Map button in the Neighbors section */ +"remoteNodes.status.viewOnMap" = "Ver no mapa"; + +/* Location: RepeaterStatusContent.swift - Accessibility label for the View on Map button */ +"remoteNodes.status.accessibility.viewNeighborsOnMap" = "Ver vizinhos no mapa"; + +/* Location: NeighborSNRMapView.swift - Navigation title for the neighbors map */ +"remoteNodes.status.neighborsMapTitle" = "Mapa de vizinhos"; + +/* Location: NeighborSNRMapView.swift - Map pill and pushed-list title counting neighbors that could not be plotted - %d is the count */ +"remoteNodes.status.neighborsNotShown" = "%d vizinhos não mostrados"; + +/* Location: NeighborSNRMapBuilder.swift - Unit suffix for the SNR value in the map midpoint badge, matching the " · dB" form */ +"remoteNodes.status.snrBadgeUnit" = "dB"; + +// MARK: - Location Map + +/* Location: SharedNodeStatusViews.swift - Accessibility label for the location "View on Map" affordance */ +"remoteNodes.status.accessibility.viewLocationOnMap" = "Ver localização no mapa"; + +/* Location: NodeStatusRoute.swift - Navigation title for the live location map */ +"remoteNodes.status.locationMapTitle" = "Localização"; + +// MARK: - History + +/* Location: RepeaterStatusViewModel.swift - Delta timestamp (minutes ago) */ +"remoteNodes.history.vsMinutesAgo" = "Desde a última visita (há %dm)"; + +/* Location: RepeaterStatusViewModel.swift - Delta timestamp (hours ago) */ +"remoteNodes.history.vsHoursAgo" = "Desde a última visita (há %dh)"; + +/* Location: RepeaterStatusViewModel.swift - Delta timestamp (date) */ +"remoteNodes.history.vsDate" = "Desde %@"; + +/* Location: NodeStatusHistoryView.swift - History navigation title */ +"remoteNodes.history.title" = "Histórico"; + +/* Location: NeighborRow - New neighbor badge */ +"remoteNodes.history.new" = "Novo"; + +/* Location: NeighborRow - Not seen status */ +"remoteNodes.history.notSeen" = "Não visto"; + +/* Location: NodeStatusHistoryView.swift - Empty state message */ +"remoteNodes.history.checkBack" = "É registado um instantâneo no máximo a cada 15 minutos. Volte depois da próxima visita para ver as tendências."; + +/* Location: NodeStatusHistoryView.swift - Time range picker */ +"remoteNodes.history.week" = "1 sem"; + +/* Location: NodeStatusHistoryView.swift - Time range picker */ +"remoteNodes.history.month" = "1M"; + +/* Location: NodeStatusHistoryView.swift - Time range picker */ +"remoteNodes.history.threeMonths" = "3M"; + +/* Location: NodeStatusHistoryView.swift - Time range picker */ +"remoteNodes.history.all" = "Tudo"; + +/* Location: NodeStatusHistoryView.swift - Battery chart title */ +"remoteNodes.history.battery" = "Bateria"; + +/* Location: NodeStatusHistoryView.swift - SNR chart title */ +"remoteNodes.history.snr" = "SNR"; + +/* Location: NodeStatusHistoryView.swift - RSSI chart title */ +"remoteNodes.history.rssi" = "RSSI"; + +/* Location: NodeStatusHistoryView.swift - Noise floor chart title */ +"remoteNodes.history.noiseFloor" = "Ruído de fundo"; + +/* Location: NodeStatusHistoryView.swift - Neighbor count chart title */ +/* Location: RadioMetricCharts.swift - Sent packets chart title, under the Packets group header */ +"remoteNodes.history.packetsSent" = "Enviados"; + +/* Location: RadioMetricCharts.swift - Received packets chart title, under the Packets group header */ +"remoteNodes.history.packetsReceived" = "Recebidos"; + +/* Location: RadioMetricCharts.swift - Receive errors chart title, under the Packets group header */ +"remoteNodes.history.receiveErrors" = "Erros"; +/* Location: RadioMetricCharts.swift - Direct packet series legend */ +"remoteNodes.history.direct" = "Direto"; +/* 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" = "Duplicados"; +/* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ +"remoteNodes.history.packets" = "Pacotes"; + +/* Location: NeighborHistoryView.swift - Active status */ +/* Location: NeighborHistoryView.swift - Last seen status */ +/* Location: NeighborHistoryView.swift - Neighbors section title */ +/* Location: StatusDeltaView.swift - Accessibility: metric increased */ +"remoteNodes.history.a11y.increased" = "aumentou"; + +/* Location: StatusDeltaView.swift - Accessibility: metric decreased */ +"remoteNodes.history.a11y.decreased" = "diminuiu"; + +/* Location: StatusDeltaView.swift - Accessibility: metric improved */ +"remoteNodes.history.a11y.improved" = "melhorou"; + +/* Location: StatusDeltaView.swift - Accessibility: metric degraded */ +"remoteNodes.history.a11y.degraded" = "piorou"; + +/* Location: StatusDeltaView.swift - Accessibility: delta description format (quality, direction, value, unit) */ +"remoteNodes.history.a11y.deltaDescription" = "%1$@, %2$@ em %3$@%4$@"; + +/* Location: HistoryTimeRangePicker.swift - Accessibility label for time range picker */ +"remoteNodes.history.timeRange" = "Intervalo de tempo"; + +/* Location: NodeStatusHistoryView.swift - Footer about data retention */ +"remoteNodes.history.retentionNotice" = "Os dados do histórico com mais de um ano são removidos automaticamente."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Radio section header */ +"remoteNodes.history.radioSection" = "Rádio"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Sensors section header */ +"remoteNodes.history.sensorsSection" = "Sensores"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Neighbors section header */ +"remoteNodes.history.neighborsSection" = "Vizinhos"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Location section header */ +"remoteNodes.history.locationSection" = "Localização"; + +/* Location: LocationHistorySection.swift - Purpose: Header for the location report list beneath the map preview */ +"remoteNodes.history.locationReportsHeader" = "Histórico de localização"; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ +"remoteNodes.history.noSnapshotsMessage" = "Ligue a este nodo pelo menos uma vez para ver o histórico."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ +"remoteNodes.history.sectionNotCaptured" = "Estes dados são capturados ao visualizar a secção %@ durante uma sessão de telemetria em direto."; + +/* Location: TelemetryHistoryOverviewView.swift - Purpose: Navigation title */ +"remoteNodes.history.overviewTitle" = "Histórico de telemetria"; + +// MARK: - Room Conversation + +/* Location: RoomConversationView.swift - Disconnected status */ +"remoteNodes.room.disconnected" = "Desligado"; + +/* Location: RoomConversationView.swift - Empty state title */ +"remoteNodes.room.noMessagesYet" = "Ainda não há mensagens públicas"; + +/* Location: RoomConversationView.swift - Empty state hint */ +"remoteNodes.room.beFirstToPost" = "Seja o primeiro a publicar"; + +/* Location: RoomConversationView.swift - Input placeholder */ +"remoteNodes.room.publicMessage" = "Mensagem pública"; + +/* Location: RoomConversationView.swift - Read-only banner */ +"remoteNodes.room.viewOnlyBanner" = "Só de leitura - entre como membro para publicar"; + +/* Location: RoomConversationView.swift - Hint text for read-only banner */ +"remoteNodes.room.viewOnlyHint" = "Toque para iniciar sessão"; + +/* Location: RoomConversationView.swift - Room info sheet title */ +"remoteNodes.room.infoTitle" = "Informações da sala"; + +/* Location: RoomConversationView.swift - Details section header */ +"remoteNodes.room.details" = "Detalhes"; + +/* Location: RoomConversationView.swift - Permission label */ +"remoteNodes.room.permission" = "Permissão"; + +/* Location: RoomInfoSheet.swift, RoomConversationView.swift - Guest permission level */ +"remoteNodes.permission.guest" = "Convidado"; + +/* Location: RoomInfoSheet.swift, RoomConversationView.swift - Member (read-write) permission level */ +"remoteNodes.permission.member" = "Membro"; + +/* Location: RoomInfoSheet.swift, RoomConversationView.swift - Admin permission level */ +"remoteNodes.permission.admin" = "Administrador"; + +/* Location: RoomConversationView.swift - Status label */ +"remoteNodes.room.status" = "Estado"; + +/* Location: RoomConversationView.swift - Connected status */ +"remoteNodes.room.connected" = "Ligado"; + +/* Location: RoomConversationView.swift - Activity section header */ +"remoteNodes.room.activity" = "Atividade"; + +/* Location: RoomConversationView.swift - Last connected label */ +"remoteNodes.room.lastConnected" = "Última ligação"; + +/* Location: RoomConversationView.swift - Identification section header */ +"remoteNodes.room.identification" = "Identificação"; + +/* Location: RoomConversationView.swift - Public key label */ +"remoteNodes.room.publicKey" = "Chave pública"; + +/* Location: RoomConversationView.swift - Disconnected banner text */ +"remoteNodes.room.disconnectedBanner" = "Desligado"; + +/* Location: RoomConversationView.swift - Accessibility hint for disconnected banner */ +"remoteNodes.room.disconnectedHint" = "Toque para voltar a ligar"; + +/* Location: RoomConversationView.swift - VoiceOver announcement when room reconnects */ +"remoteNodes.room.reconnected" = "Sala novamente ligada"; + +// MARK: - Room Message Bubble + +/* Location: RoomMessageBubble.swift - Accessibility hint for retry button */ +"remoteNodes.room.message.retryHint" = "Toque duas vezes para reenviar esta mensagem"; + +/* Location: RoomMessageBubble.swift - Accessibility label for failed message status */ +"remoteNodes.room.message.status.failedLabel" = "Não foi possível enviar a mensagem. Toque duas vezes no botão de tentar novamente para reenviar."; + +/* Location: RoomMessageBubble.swift - Accessibility label for sending message status */ +"remoteNodes.room.message.status.sendingLabel" = "A enviar mensagem"; + +/* Location: RoomMessageBubble.swift - Accessibility label for delivered message status */ +"remoteNodes.room.message.status.deliveredLabel" = "Mensagem entregue"; + +// MARK: - Room Status + +/* Location: RoomStatusView.swift - Navigation title */ +"remoteNodes.roomStatus.title" = "Estado da sala"; + +/* Location: RoomStatusView.swift - Posts received label */ +"remoteNodes.roomStatus.postsReceived" = "Publicações recebidas"; + +/* Location: RoomStatusView.swift - Posts pushed label */ +"remoteNodes.roomStatus.postsPushed" = "Publicações enviadas"; + +// MARK: - Room Settings + +/* Location: RoomSettingsView.swift - Navigation title */ +"remoteNodes.roomSettings.title" = "Definições da sala"; + +/* Location: RoomSettingsView.swift - Room settings section header */ +"remoteNodes.roomSettings.roomSettingsSection" = "Definições da sala"; + +/* Location: RoomSettingsView.swift - Room settings section footer */ +"remoteNodes.roomSettings.roomSettingsFooter" = "Palavra-passe de convidado e acesso só de leitura."; + +/* Location: RoomSettingsView.swift - Guest password label */ +"remoteNodes.roomSettings.guestPassword" = "Palavra-passe de convidado"; + +/* Location: RoomSettingsView.swift - Allow read-only toggle label */ +"remoteNodes.roomSettings.allowReadOnly" = "Permitir só de leitura"; + +/* Location: RoomSettingsView.swift - Allow read-only footer */ +"remoteNodes.roomSettings.allowReadOnlyFooter" = "Permite que utilizadores sem palavra-passe se liguem no modo só de leitura."; + +/* Location: RoomSettingsView.swift - Apply room settings button */ +"remoteNodes.roomSettings.applyRoomSettings" = "Aplicar definições da sala"; + +/* Location: RoomSettingsView.swift - Room behavior section footer */ +"remoteNodes.roomSettings.behaviorFooter" = "Intervalos de Advert e saltos flood."; + +/* Location: RoomSettingsView.swift - Identity section footer */ +/* Location: RoomSettingsView.swift - Reboot confirmation title */ +"remoteNodes.roomSettings.rebootConfirmTitle" = "Reiniciar sala?"; + +/* Location: RoomSettingsView.swift - Reboot confirmation message */ +"remoteNodes.roomSettings.rebootMessage" = "A sala irá reiniciar e ficará temporariamente indisponível."; + +/* Location: RoomSettingsView.swift - Radio restart warning */ +"remoteNodes.roomSettings.radioRestartWarning" = "Estas alterações só terão efeito depois de a sala ser reiniciada"; + +/* Location: RoomSettingsView.swift - No service error */ +/* Location: RoomSettingsView.swift - Not connected error */ +/* Location: RoomSettingsView.swift - Clock ahead error */ +// MARK: - Room Info Sheet (additions) + +/* Location: RoomInfoSheet.swift - Telemetry button */ +/* Location: RoomInfoSheet.swift - Management button */ +// MARK: - Node CLI + +/* Location: NodeCliView.swift - Banner shown when connected to a node - %@ is node name */ +"remoteNodes.nodeCli.bannerConnected" = "Ligado a %@"; + +/* Location: NodeCliView.swift - Hint shown below connected banner */ +"remoteNodes.nodeCli.bannerHint" = "Introduza 'help' para ver os comandos disponíveis."; + +/* Location: NodeCliViewModel.swift - Toast after reboot command succeeds */ +"remoteNodes.nodeCli.rebootSent" = "Comando de reinício enviado"; + +/* Location: NodeCliViewModel.swift - Toast after command is cancelled */ +"remoteNodes.nodeCli.cancelled" = "Comando cancelado"; + +/* Location: NodeCliViewModel.swift - Header line in help output */ +"remoteNodes.nodeCli.helpHeader" = "Comandos disponíveis:"; + +/* Location: NodeCliViewModel.swift - Help entry for 'help' command */ +"remoteNodes.nodeCli.helpHelp" = " help\n Mostrar esta ajuda"; + +/* Location: NodeCliViewModel.swift - Help entry for 'clear' command */ +"remoteNodes.nodeCli.helpClear" = " clear\n Limpar o terminal"; + +/* Location: NodeCliViewModel.swift - Help entry for 'clear stats' command */ +"remoteNodes.nodeCli.helpClearStats" = " clear stats\n Repor as estatísticas do nodo"; + +/* Location: NodeCliViewModel.swift - Help entry for 'reboot' command */ +"remoteNodes.nodeCli.helpReboot" = " reboot\n Reiniciar este nodo"; + +/* Location: NodeCliViewModel.swift - Passthrough note at end of help output */ +"remoteNodes.nodeCli.helpPassthrough" = "Qualquer outra entrada é enviada ao nodo."; + +// MARK: - Shared + +/* Location: Multiple files - Cancel button */ +"remoteNodes.cancel" = "Cancelar"; + +/* Location: Multiple files - Done button */ +"remoteNodes.done" = "OK"; + +/* Location: Multiple files - Name label */ +"remoteNodes.name" = "Nome"; diff --git a/MC1/Resources/Localization/pt.lproj/RemoteNodes.stringsdict b/MC1/Resources/Localization/pt.lproj/RemoteNodes.stringsdict new file mode 100644 index 000000000..22cf942f4 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/RemoteNodes.stringsdict @@ -0,0 +1,29 @@ + + + + + + remoteNodes.status.neighborsNotShown + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d vizinho não mostrado + other + %d vizinhos não mostrados + + + + diff --git a/MC1/Resources/Localization/pt.lproj/Settings.strings b/MC1/Resources/Localization/pt.lproj/Settings.strings new file mode 100644 index 000000000..c1250f178 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Settings.strings @@ -0,0 +1,1729 @@ +/* + Settings.strings + MC1 + + European Portuguese (pt-PT) translation of strings for the Settings feature. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Main Settings View + +/* Navigation title for the main settings screen */ +"title" = "Definições"; + +/* Placeholder shown in split view detail when no setting is selected */ +"selectSetting" = "Selecione uma definição"; + +/* Section header for device-specific settings */ +"myDevice.header" = "O meu dispositivo"; + +/* Section header for app-level settings */ +"appSettings.header" = "Definições da app"; + +/* Footer text for the advanced settings row */ +/* Label for the advanced settings navigation row */ +"advancedSettings.title" = "Definições avançadas"; + +/* Version display prefix with version number */ +"version" = "v%@"; + +/* Build number display with build number */ +"build" = "Compilação %@"; + +// MARK: - Demo Mode Section + +/* Section header for demo mode */ +"demoMode.header" = "Modo demo"; + +/* Toggle label to enable demo mode */ +"demoMode.enabled" = "Ativado"; + +/* Footer explaining what demo mode does */ +"demoMode.footer" = "O modo demo permite testar sem hardware, com dados simulados."; + +// MARK: - Device Section + +/* Section header for device information */ +"device.header" = "Dispositivo"; + +/* Status shown when device is connected */ +"device.connected" = "Ligado"; + +/* Footer shown when no device is connected */ +"device.noDeviceConnected" = "Nenhum dispositivo MeshCore ligado"; + +/* Button to connect a device */ +"device.connect" = "Ligar dispositivo"; + +// MARK: - Device Info View + +/* Navigation title for device info screen */ +"deviceInfo.title" = "Informações do dispositivo"; + +/* Section header for connection status */ +"deviceInfo.connection.header" = "Ligação"; + +/* Label for connection status */ +"deviceInfo.connection.status" = "Estado"; + +/* Section header for power and storage */ +"deviceInfo.powerStorage.header" = "Energia e armazenamento"; + +/* Label for battery level */ +"deviceInfo.battery" = "Bateria"; + +/* Label for storage used */ +"deviceInfo.storageUsed" = "Armazenamento usado"; + +/* Combined label for battery and storage when loading */ +"deviceInfo.batteryAndStorage" = "Bateria e armazenamento"; + +/* Section header for firmware information */ +"deviceInfo.firmware.header" = "Firmware"; + +/* Label for firmware version */ +"deviceInfo.firmwareVersion" = "Versão do firmware"; + +/* Label for build date */ +"deviceInfo.buildDate" = "Data de compilação"; + +/* Label for manufacturer */ +"deviceInfo.manufacturer" = "Fabricante"; + +/* Placeholder when a value is unknown */ +"deviceInfo.unknown" = "Desconhecido"; + +/* Section header for device capabilities */ +"deviceInfo.capabilities.header" = "Capacidades"; + +/* Label for max nodes capability */ +"deviceInfo.maxNodes" = "Máximo de nodos"; + +/* Label for max channels capability */ +"deviceInfo.maxChannels" = "Máximo de canais"; + +/* Label for max TX power capability */ +"deviceInfo.maxTxPower" = "Potência de TX máxima"; + +/* TX power display format with dBm unit */ +"deviceInfo.txPowerFormat" = "%@ dBm"; + +/* Firmware version fallback format when no version string is reported - %@ is the numeric version code */ +"deviceInfo.firmwareVersionFormat" = "v%@"; + +/* Section header for identity information */ +/* Label for public key */ +"deviceInfo.publicKey" = "Chave pública"; + +/* Button to share contact information */ +"deviceInfo.shareContact" = "Partilhar o meu contacto"; + +/* Title for ContentUnavailableView when no device is connected */ +"deviceInfo.noDevice.title" = "Nenhum dispositivo ligado"; + +/* Description for ContentUnavailableView when no device is connected */ +"deviceInfo.noDevice.description" = "Ligue um dispositivo MeshCore para ver as respetivas informações"; + +/* Fallback manufacturer name */ +"deviceInfo.defaultManufacturer" = "Dispositivo MeshCore"; + +// MARK: - Public Key View + +/* Navigation title for public key screen */ +"publicKey.title" = "Chave pública"; + +/* Section header describing the key type */ +"publicKey.header" = "Chave pública Ed25519 de 32 bytes"; + +/* Footer explaining the public key's purpose */ +"publicKey.footer" = "Esta chave identifica de forma exclusiva o dispositivo na rede mesh"; + +// MARK: - Private Key View + +/* Section header describing the key type */ +"privateKey.header" = "Chave privada Ed25519 de 64 bytes"; + +/* Section header for base64 representation */ +"publicKey.base64.header" = "Base64"; + +/* Button to copy key to clipboard */ +"publicKey.copy" = "Copiar para a área de transferência"; + +// MARK: - Device Selection Sheet + +/* Navigation title for device selection */ +"deviceSelection.title" = "Ligar dispositivo"; + +/* Button to connect to selected device */ +/* Section header for previously paired devices */ +"deviceSelection.previouslyPaired" = "Emparelhados anteriormente"; + +/* Footer text prompting user to select a device */ +/* Button to connect via WiFi */ +"deviceSelection.connectViaWifi" = "Ligar via Wi-Fi"; + +/* Button to scan for Bluetooth devices */ +"deviceSelection.scanBluetooth" = "Procurar dispositivo Bluetooth"; + +/* Title for empty state when no devices are paired */ +"deviceSelection.noPairedDevices" = "Nenhum dispositivo emparelhado"; + +/* Description for empty state */ +"deviceSelection.noPairedDescription" = "Ainda não emparelhou nenhum dispositivo."; + +/* Button to scan for new devices */ +"deviceSelection.scanForDevices" = "Procurar dispositivos"; + +/* Accessibility label for a device connected to another app - %1$@ is device name */ +"deviceSelection.accessibility.connectedElsewhereLabel" = "%1$@, ligado a outra app"; + +/* Accessibility label for a device - %1$@ is device name, %2$@ is connection type */ +"deviceSelection.accessibility.deviceLabel" = "%1$@, %2$@"; + +/* Accessibility hint for a device connected to another app */ +"deviceSelection.accessibility.connectedElsewhereHint" = "Este dispositivo está a ser usado por outra app. Ligar pode causar problemas de comunicação."; + +/* Accessibility hint for selecting a device */ +"deviceSelection.accessibility.selectHint" = "Toque duas vezes para ligar"; + +/* Accessibility hint for a device that is out of Bluetooth range */ +"deviceSelection.accessibility.outOfRangeHint" = "O dispositivo está fora do alcance do Bluetooth"; + +/* Label shown when device is connected to another app */ +"deviceSelection.connectedElsewhere" = "Ligado noutro sítio"; + +/* Fallback connection type description */ +"deviceSelection.bluetooth" = "Bluetooth"; + +// MARK: - Location Picker View + +/* Navigation title for location picker */ +"locationPicker.title" = "Definir localização"; + +/* Marker title for node location on map */ +/* Button to clear the selected location */ +"locationPicker.clearLocation" = "Limpar localização"; + +/* Button to drop a pin at the map center */ +"locationPicker.dropPin" = "Colocar alfinete no centro"; + +/* Label for latitude display */ +"locationPicker.latitude" = "Latitude:"; + +/* Label for longitude display */ +"locationPicker.longitude" = "Longitude:"; + +// MARK: - Trusted Contacts Picker + +/* Navigation title for trusted contacts picker */ +"trustedContacts.title" = "Contactos de confiança"; + +/* Title for empty state when no contacts exist */ +"trustedContacts.noContacts" = "Nenhum contacto"; + +/* Description for empty state */ +"trustedContacts.noContactsDescription" = "Adicione contactos para selecionar os de confiança"; + +/* Title for empty state when search returns no results */ +"trustedContacts.noResults" = "Nenhum resultado"; + +/* Description for empty state when search returns no results */ +"trustedContacts.noResultsDescription" = "Nenhum contacto corresponde a \"%@\""; + +/* Search prompt for trusted contacts list */ +"trustedContacts.searchPrompt" = "Pesquisar contactos"; + +/* Toggle label for showing only favorite contacts */ +"trustedContacts.favoritesOnly" = "Apenas favoritos"; + +/* Apply button label */ +"trustedContacts.apply" = "Aplicar"; + +// MARK: - About Section + +/* Section header for about links */ +"about.header" = "Acerca"; + +/* Link to MeshCore website */ +"about.website" = "Website do MeshCore"; + +/* Link to MeshCore online map */ +"about.onlineMap" = "Mapa online do MeshCore"; + +/* Link to GitHub repository */ +"about.github" = "GitHub"; + +/* Link to the privacy policy */ +"about.privacyPolicy" = "Política de privacidade"; + +// MARK: - Advanced Radio Section + +/* Section header for radio configuration */ +"advancedRadio.header" = "Configuração do rádio"; + +/* Label for frequency input */ +"advancedRadio.frequency" = "Frequência (MHz)"; + +/* Placeholder for frequency text field */ +"advancedRadio.frequencyPlaceholder" = "MHz"; + +/* Label for bandwidth picker */ +"advancedRadio.bandwidth" = "Largura de banda (kHz)"; + +/* Label for spreading factor picker */ +"advancedRadio.spreadingFactor" = "Fator de espalhamento"; + +/* Label for coding rate picker */ +"advancedRadio.codingRate" = "Taxa de codificação"; + +/* Label for TX power input */ +"advancedRadio.txPower" = "Potência de TX (dBm)"; + +/* Placeholder for TX power text field */ +"advancedRadio.txPowerPlaceholder" = "dBm"; + +/* Accessibility label for bandwidth picker options - %@ is formatted bandwidth value */ +"advancedRadio.accessibility.bandwidthLabel" = "%@ quilohertz"; + +/* Accessibility label for spreading factor picker options - %d is the factor value */ +"advancedRadio.accessibility.spreadingFactorLabel" = "Fator de espalhamento %d"; + +/* Accessibility label for coding rate picker options - %d is the rate value */ +"advancedRadio.accessibility.codingRateLabel" = "Taxa de codificação %d"; + +/* Accessibility hint for bandwidth picker */ +"advancedRadio.accessibility.bandwidthHint" = "Valores menores aumentam o alcance, mas diminuem a velocidade"; + +/* Accessibility hint for spreading factor picker */ +"advancedRadio.accessibility.spreadingFactorHint" = "Valores maiores aumentam o alcance, mas diminuem a velocidade"; + +/* Accessibility hint for coding rate picker */ +"advancedRadio.accessibility.codingRateHint" = "Valores maiores adicionam correção de erros, mas diminuem a velocidade"; + +/* Button to apply radio settings */ +"advancedRadio.apply" = "Aplicar definições do rádio"; + +/* Footer warning about incorrect radio settings */ +"advancedRadio.footer" = "Aviso: definições incorretas podem impedir a comunicação com outros dispositivos mesh."; + +/* Error message for invalid input */ +"advancedRadio.invalidInput" = "Valores de entrada inválidos ou dispositivo não ligado"; + +/* Toggle label for repeat mode in advanced radio */ +"advancedRadio.repeatMode" = "Modo de repetidor"; + +/* Footer explaining repeat mode in advanced radio */ +"advancedRadio.repeatMode.footer" = "Cria um repetidor local numa frequência dedicada. Útil para trilhos e zonas remotas. Frequências válidas: 433, 869.495, 918 MHz."; + +// MARK: - Path Hash Mode Section + +/* Section header for path hash mode */ +"pathHashMode.header" = "Tamanho do hash do caminho"; + +/* Label for path hash mode picker */ +"pathHashMode.label" = "Tamanho do hash"; + +/* Path hash mode option: 1 byte */ +"pathHashMode.oneByte" = "1 byte"; + +/* Path hash mode option: 2 bytes */ +"pathHashMode.twoBytes" = "2 bytes"; + +/* Path hash mode option: 3 bytes */ +"pathHashMode.threeBytes" = "3 bytes"; + +/* Footer explaining path hash mode tradeoff */ +"pathHashMode.footer" = "Hashes maiores reduzem colisões de routing, mas limitam o número máximo de saltos por caminho. Os repetidores com firmware anterior a 1.14.0 não repetem mensagens com hash maior que 1 byte."; + +// MARK: - Default Flood Scope Section + +/* Section header for default flood scope picker */ +"defaultFloodScope.header" = "Âmbito de flood predefinido"; + +/* Footer explaining default flood scope */ +"defaultFloodScope.footer" = "Quando definido, o dispositivo aplica este âmbito aos envios flood, a menos que um âmbito específico do canal o substitua. O âmbito é guardado no dispositivo e mantém-se após reinícios."; + +/* Option to clear the persisted default flood scope */ +"defaultFloodScope.disabled" = "Nenhum"; + +// MARK: - Battery Curve Section + +/* Accessibility label for voltage text field - %d is the percentage level */ +"batteryCurve.accessibility.voltageLabel" = "Tensão a %d por cento"; + +/* Accessibility value for voltage text field - %d is the millivolt value */ +"batteryCurve.accessibility.voltageValue" = "%d milivolts"; + +/* Accessibility hint for voltage text field */ +"batteryCurve.accessibility.voltageHint" = "Introduza a tensão esperada neste nível de carga"; + +/* Section header for battery curve */ +"batteryCurve.header" = "Curva da bateria"; + +/* Footer explaining battery curve configuration */ +"batteryCurve.footer" = "Configure a curva de tensão para percentagem da bateria do dispositivo."; + +/* Label for preset picker */ +"batteryCurve.preset" = "Predefinição"; + +/* Option for custom battery curve */ +"batteryCurve.custom" = "Personalizado"; + +/* Disclosure group label for editing values */ +"batteryCurve.editValues" = "Editar valores"; + +/* Validation error for value out of range - %d is the percentage level */ +"batteryCurve.validation.outOfRange" = "O valor em %d%% tem de ser 1000-99999 mV"; + +/* Validation error for non-descending values */ +"batteryCurve.validation.notDescending" = "Os valores têm de estar por ordem decrescente"; + +/* Unit label for millivolts */ +"batteryCurve.mV" = "mV"; + +// MARK: - Bluetooth Section + +/* Section header for Bluetooth settings */ +"bluetooth.header" = "Bluetooth"; + +/* Label for PIN type picker */ +"bluetooth.pinType" = "Tipo de PIN"; + +/* PIN type option for default PIN */ +"bluetooth.pinType.default" = "Predefinido"; + +/* PIN type option for custom PIN */ +"bluetooth.pinType.custom" = "PIN personalizado"; + +/* Label showing current PIN */ +"bluetooth.currentPin" = "PIN atual"; + +/* Button to change the PIN */ +"bluetooth.changePin" = "Alterar PIN"; + +/* Button to change the device display name */ +"bluetooth.changeDisplayName" = "Alterar nome apresentado"; + +/* Footer explaining default PIN */ +"bluetooth.defaultPinFooter" = "O PIN predefinido é 123456. Os dispositivos com ecrã mostram o próprio PIN."; + +/* Alert title for setting custom PIN */ +"bluetooth.alert.setPin.title" = "Definir PIN personalizado"; + +/* Alert title for changing custom PIN */ +"bluetooth.alert.changePin.title" = "Alterar PIN personalizado"; + +/* Placeholder for PIN text field */ +"bluetooth.pinPlaceholder" = "PIN de 6 dígitos"; + +/* Button to set the PIN */ +"bluetooth.setPin" = "Definir PIN"; + +/* Alert message for setting PIN */ +"bluetooth.alert.setPin.message" = "Introduza um PIN de 6 dígitos. O dispositivo irá reiniciar para aplicar a alteração."; + +/* Alert message for changing PIN */ +"bluetooth.alert.changePin.message" = "Introduza um novo PIN de 6 dígitos. O dispositivo irá reiniciar para aplicar a alteração."; + +/* Alert title for confirming PIN type change */ +"bluetooth.alert.changePinType.title" = "Alterar o tipo de PIN?"; + +/* Button to confirm change */ +"bluetooth.alert.change" = "Alterar"; + +/* Alert message for PIN type change */ +"bluetooth.alert.changePinType.message" = "O dispositivo irá reiniciar para aplicar a alteração."; + +/* Error for invalid PIN format */ +"bluetooth.error.invalidPin" = "O PIN tem de ser um número de 6 dígitos entre 100000 e 999999"; + +// MARK: - Contacts Settings Section + +/* Section header for nodes/contacts settings */ +/* Toggle label for auto-add nodes */ +/* Description for auto-add nodes toggle */ +// MARK: - Nodes Settings Section + +/* Section header for nodes settings */ +"nodes.header" = "Nodos"; + +/* Label for auto-add mode picker */ +"nodes.autoAddMode" = "Modo de adição automática"; + +/* Auto-add mode: manual */ +"nodes.autoAddMode.manual" = "Manual"; + +/* Auto-add mode: manual description */ +"nodes.autoAddMode.manualDescription" = "Reveja todos os nodos em Descobrir antes de adicionar"; + +/* Auto-add mode: selected types */ +"nodes.autoAddMode.selectedTypes" = "Tipos selecionados"; + +/* Auto-add mode: selected types description */ +"nodes.autoAddMode.selectedTypesDescription" = "Adicionar automaticamente apenas os tipos ativados abaixo"; + +/* Auto-add mode: all */ +"nodes.autoAddMode.all" = "Todos"; + +/* Auto-add mode: all description */ +"nodes.autoAddMode.allDescription" = "Adicionar automaticamente todos os nodos descobertos"; + +/* Section header for auto-add types */ +/* Toggle label for auto-add contacts */ +"nodes.autoAddContacts" = "Contactos"; + +/* Toggle label for auto-add repeaters */ +"nodes.autoAddRepeaters" = "Repetidores"; + +/* Toggle label for auto-add room servers */ +"nodes.autoAddRoomServers" = "Servidores de sala"; + +/* Section header for storage settings */ +/* Toggle label for overwrite oldest */ +"nodes.overwriteOldest" = "Substituir o mais antigo"; + +/* Description for overwrite oldest toggle */ +"nodes.overwriteOldestDescription" = "Quando o armazenamento estiver cheio, substitui o nodo não favorito mais antigo"; + +/* Picker label for max hop distance */ +"nodes.maxHops" = "Limite máximo de saltos na adição automática"; + +/* No limit option */ +"nodes.maxHops.noLimit" = "Sem limite"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Apenas direto"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 salto"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d saltos"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "Os nodos além da distância de saltos selecionada não serão adicionados automaticamente."; + +// MARK: - Auto-Remove Old Nodes Section + +/* Toggle label / section header for stale node cleanup */ +"nodes.staleCleanup.header" = "Remover automaticamente nodos antigos"; + +/* Label for threshold picker */ +"nodes.staleCleanup.threshold" = "Remover nodos mais antigos que"; + +/* Picker placeholder when no threshold is selected */ +"nodes.staleCleanup.select" = "Selecionar"; + +/* Threshold option: number of days (%d = day count) */ +"nodes.staleCleanup.days" = "%d dias"; + +/* Footer when auto-remove is enabled (%d = day count) */ +"nodes.staleCleanup.footerEnabled" = "Os nodos não favoritos não modificados em %d dias são removidos automaticamente na ligação."; + +/* Footer describing the auto-remove feature (shown when toggle is off) */ +"nodes.staleCleanup.footerDisabled" = "Remove automaticamente nodos não favoritos que não foram modificados num período definido. Os favoritos nunca são removidos."; + +/* Footer when toggle is on but no threshold selected yet */ +"nodes.staleCleanup.footerSelect" = "Escolha durante quanto tempo manter os nodos. Os favoritos nunca são removidos."; + +/* Footer when enabled but device is disconnected */ +"nodes.staleCleanup.footerDisconnected" = "Irá verificar nodos antigos na próxima ligação. Os favoritos nunca são removidos."; + +/* Last cleanup date row (%@ = relative date) */ +"nodes.staleCleanup.lastRun" = "Última verificação %@"; + +// MARK: - Device Actions Section + +/* Section header for device actions */ +"deviceActions.header" = "Dispositivo"; + +/* Button to reboot the device */ +"deviceActions.rebootDevice" = "Reiniciar dispositivo"; + +/* Text shown while rebooting */ +"deviceActions.rebooting" = "A reiniciar…"; + +/* Alert title for reboot confirmation */ +"deviceActions.alert.reboot.title" = "Reiniciar o dispositivo?"; + +/* Button to confirm reboot */ +"deviceActions.alert.reboot.confirm" = "Reiniciar"; + +/* Alert message for reboot confirmation */ +"deviceActions.alert.reboot.message" = "O dispositivo irá reiniciar e a ligação será interrompida temporariamente."; + +// MARK: - Danger Zone Section + +/* Section header for danger zone */ +"dangerZone.header" = "Zona de perigo"; + +/* Button to forget/unpair the device */ +"dangerZone.forgetDevice" = "Esquecer dispositivo"; + +/* Button to factory reset the device */ +"dangerZone.factoryReset" = "Repor o dispositivo de fábrica"; + +/* Text shown while resetting */ +"dangerZone.resetting" = "A repor…"; + +/* Footer explaining factory reset */ +"dangerZone.footer" = "A reposição de fábrica apaga todos os contactos, mensagens e definições no dispositivo."; + +/* Confirmation dialog title for forget device */ +"dangerZone.dialog.forget.title" = "Esquecer dispositivo"; + +/* Confirmation dialog message for forget device */ +"dangerZone.dialog.forget.message" = "O dispositivo será desemparelhado. É possível escolher manter ou eliminar as mensagens, os contactos e os canais."; + +/* Button to forget device but keep data */ +"dangerZone.dialog.forget.keepData" = "Esquecer dispositivo"; + +/* Button to forget device and delete all associated data */ +"dangerZone.dialog.forget.deleteAll" = "Esquecer dispositivo e eliminar dados"; + +/* Alert title for factory reset confirmation */ +"dangerZone.alert.reset.title" = "Repor de fábrica"; + +/* Button to confirm reset */ +"dangerZone.alert.reset.confirm" = "Repor"; + +/* Alert message for factory reset */ +"dangerZone.alert.reset.message" = "Isto irá apagar todos os dados do dispositivo, incluindo contactos, mensagens e definições. Toque em Remover quando for pedido para desemparelhar o dispositivo."; + +/* Error when services are not available */ +"dangerZone.error.servicesUnavailable" = "Serviços indisponíveis"; + +/* Button to remove non-favorite nodes */ +"dangerZone.removeUnfavorited" = "Remover nodos não favoritos"; + +/* Text shown while removing unfavorited nodes */ +"dangerZone.removing" = "A remover…"; + +/* Label shown after successful removal */ +"dangerZone.removed" = "Nodos removidos"; + +/* Alert title for remove non-favorite confirmation */ +"dangerZone.alert.removeUnfavorited.title" = "Remover nodos não favoritos"; + +/* Button to confirm removal */ +"dangerZone.alert.removeUnfavorited.confirm" = "Remover nodos"; + +/* Alert title for removal result */ +"dangerZone.alert.removeUnfavorited.resultTitle" = "Resultado da remoção"; + +/* Alert message for remove unfavorited (%d = count) */ +"dangerZone.alert.removeUnfavorited.message" = "Isto irá eliminar permanentemente %d nodos não marcados como favoritos do dispositivo e da app, juntamente com as respetivas mensagens."; + +/* Partial success message (%d = removed, %d = total) */ +"dangerZone.alert.removeUnfavorited.partial" = "Removidos %d de %d nodos. A ligação foi interrompida — toque novamente no botão para tentar remover os nodos restantes."; + +/* Message when no non-favorite nodes to remove */ +"dangerZone.alert.removeUnfavorited.noneFound" = "Não há nodos não favoritos para remover."; + +// MARK: - Diagnostics Section + +/* Section header for diagnostics */ +"diagnostics.header" = "Diagnóstico"; + +/* Button to export debug logs */ +"diagnostics.exportLogs" = "Exportar registos de depuração"; + +/* Button to clear debug logs */ +"diagnostics.clearLogs" = "Limpar registos de depuração"; + +/* Footer explaining log export */ +"diagnostics.footer" = "A exportação inclui os registos de depuração de todas as sessões da app. Os registos são guardados localmente e removidos automaticamente ao fim de 7 dias."; + +/* Alert title for clear logs confirmation */ +"diagnostics.alert.clear.title" = "Limpar registos de depuração"; + +/* Button to confirm clear */ +"diagnostics.alert.clear.confirm" = "Limpar"; + +/* Alert message for clear logs */ +"diagnostics.alert.clear.message" = "Isto irá eliminar todos os registos de depuração armazenados. Os ficheiros de registo já exportados não serão afetados."; + +/* Error when export fails */ +"diagnostics.error.exportFailed" = "Não foi possível criar o ficheiro de exportação"; + +// MARK: - Chat Settings + +/* Navigation title for chat settings page */ +"chatSettings.title" = "Chats"; + +// MARK: - Link Preview Settings Section + +/* Section header for link content settings */ +"linkPreviews.header" = "Conteúdo de ligações"; + +/* Toggle label for the link content master switch */ +"linkPreviews.toggle" = "Mostrar conteúdo de ligações"; + +/* Toggle label for showing link content in DMs */ +"linkPreviews.showInDMs" = "Mostrar em DMs"; + +/* Toggle label for showing link content in channels */ +"linkPreviews.showInChannels" = "Mostrar em canais"; + +/* Footer explaining link content privacy implications */ +"linkPreviews.footer" = "As pré-visualizações de ligações e as imagens são obtidas através da ligação à Internet do telemóvel, não da mesh. Isto pode revelar o endereço IP ao servidor que aloja o conteúdo e pode usar dados móveis."; + +/* Footer note shown when Reduce Motion is enabled */ +"linkPreviews.reduceMotionNote" = "Reduzir movimento está ativado, por isso os GIFs não serão reproduzidos automaticamente."; + +// MARK: - Inline Image Settings Section + +/* Toggle label for auto-play GIFs */ +"inlineImages.autoPlayGifs" = "Reprodução automática de GIFs"; + +// MARK: - Map Preview Settings Section + +/* Section header for chat map preview settings */ +"mapPreviews.header" = "Pré-visualizações do mapa"; + +/* Toggle label for chat map thumbnails */ +"mapPreviews.toggle" = "Mostrar miniaturas do mapa"; + +/* Footer explaining map preview privacy implications */ +"mapPreviews.footer" = "Mostrar miniaturas do mapa obtém mosaicos do mapa para a coordenada a partir de um servidor de terceiros, o que pode revelar o endereço IP."; + +// MARK: - Node Settings Section + +/* Section header for node settings */ +"node.header" = "Nodo"; + +/* Label for node name */ +"node.name" = "Nome do nodo"; + +/* Default node name when unknown */ +/* Button text to copy */ +/* Label for set location button */ +"node.setLocation" = "Definir localização"; + +/* Text shown when location is set */ +"node.locationSet" = "Definida"; + +/* Text shown when location is not set */ +"node.locationNotSet" = "Não definida"; + +/* Toggle label for share location publicly */ +"node.shareLocationPublicly" = "Partilhar localização publicamente"; + +/* Footer explaining node visibility */ +"node.footer" = "O nome do nodo fica visível para outros utilizadores da mesh quando é partilhado."; + +/* Alert title for editing node name */ +"node.alert.editName.title" = "Editar nome do nodo"; + +// MARK: - Location Settings Section + +/* Section header for location settings */ +"location.header" = "Localização"; + +/* Footer for location settings section */ +"location.footer" = "Quando Partilhar localização publicamente está ativado, a localização será transmitida pela mesh ao enviar Adverts. Ativar Atualizar localização automaticamente atualiza a localização do dispositivo antes de enviar Adverts."; + +/* Toggle label for auto-update location */ +"location.autoUpdate" = "Atualizar localização automaticamente"; + +/* Label for GPS source picker */ +"location.gpsSource" = "Fonte de GPS"; + +/* GPS source option: phone GPS */ +"location.gpsSource.phone" = "GPS do telemóvel"; + +/* GPS source option: device GPS */ +"location.gpsSource.device" = "GPS do dispositivo"; + +/* Section header for device GPS controls */ +"location.deviceGps.header" = "GPS do dispositivo"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Ativar GPS do dispositivo"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Liga ou desliga o GPS integrado do rádio. Guardar uma localização manual no mapa desliga o GPS do dispositivo."; + +/* Detail text when location is being shared publicly */ +"location.sharingPublicly" = "Partilha pública"; + +/* Detail text when location is not being shared */ +"location.notSharing" = "Sem partilha"; + +// MARK: - Notification Settings Section + +/* Section header for notifications */ +"notifications.header" = "Notificações"; + +/* Toggle label for contact messages notifications */ +"notifications.contactMessages" = "Mensagens de contactos"; + +/* Toggle label for channel messages notifications */ +"notifications.channelMessages" = "Mensagens de canais"; + +/* Toggle label for room messages notifications */ +"notifications.roomMessages" = "Mensagens de salas"; + +/* Toggle label for new contact discovered notifications */ +"notifications.newContactDiscovered" = "Novo contacto descoberto"; + +/* Toggle label for companion discovery notifications */ +"notifications.discoveryContact" = "Companion"; + +/* Toggle label for repeater discovery notifications */ +"notifications.discoveryRepeater" = "Repetidor"; + +/* Toggle label for room discovery notifications */ +"notifications.discoveryRoom" = "Sala"; + +/* Toggle label for low battery warnings */ +"notifications.lowBattery" = "Avisos de bateria fraca"; + +/* Toggle label for reaction notifications */ +"notifications.reactions" = "Reações"; + +/* Button to enable notifications */ +"notifications.enable" = "Ativar notificações"; + +/* Label shown when notifications are disabled */ +"notifications.disabled" = "Notificações desativadas"; + +/* Button to open system settings */ +"notifications.openSettings" = "Abrir Definições"; + +/* Message shown when device not connected */ +/* Footer for notifications navigation row */ +// MARK: - Radio Preset Section + +/* Section header for radio settings */ +"radio.header" = "Rádio"; + +/* Label for radio preset picker */ +"radio.preset" = "Predefinição de rádio"; + +/* Footer explaining radio presets */ +"radio.footer" = "Escolha uma predefinição correspondente à sua região. Os dispositivos MeshCore têm de usar as mesmas definições de rádio para comunicar."; + +/* Toggle label for repeat mode */ +"radio.repeatMode" = "Modo de repetidor"; + +/* Footer explaining repeat mode */ +"radio.repeatMode.footer" = "Cria um repetidor local numa frequência dedicada. Útil para trilhos e zonas remotas."; + +/* Accessibility hint for repeat mode toggle */ +"radio.repeatMode.accessibilityHint" = "Ativar isto irá desligar da rede mesh principal"; + +/* Confirmation dialog title for enabling repeat mode */ +"radio.repeatMode.confirm.title" = "Ativar o modo de repetidor?"; + +/* Confirmation dialog message for enabling repeat mode */ +"radio.repeatMode.confirm.message" = "Será desligado da rede mesh principal. Só serão alcançáveis dispositivos na mesma frequência do modo de repetidor."; + +/* Confirmation dialog enable button */ +"radio.repeatMode.confirm.enable" = "Ativar"; + +/* Location: RadioPresetSection.swift - Footer line listing the user's region */ +"radio.regionFooter" = "A mostrar as predefinições recomendadas para %@."; + +/* Location: RadioPresetSection.swift - Footer warning when current preset isn't recommended for region */ +"radio.mismatchHint" = "O rádio está em %@, não na predefinição recomendada para %@."; + +// MARK: - Telemetry Settings Section + +/* Section header for telemetry settings */ +"telemetry.header" = "Telemetria"; + +/* Toggle label for allowing telemetry requests */ +"telemetry.allowRequests" = "Permitir pedidos de telemetria"; + +/* Description for telemetry requests toggle */ +"telemetry.allowRequestsDescription" = "Necessário para que outros utilizadores possam traçar manualmente um caminho até si. Partilha o nível da bateria."; + +/* Toggle label for including location in telemetry */ +"telemetry.includeLocation" = "Incluir localização"; + +/* Description for include location toggle */ +"telemetry.includeLocationDescription" = "Partilhar coordenadas GPS na telemetria"; + +/* Toggle label for including environment sensors */ +"telemetry.includeEnvironment" = "Incluir sensores ambientais"; + +/* Description for include environment toggle */ +"telemetry.includeEnvironmentDescription" = "Partilhar temperatura, humidade, etc."; + +/* Toggle label for trusted contacts only */ +"telemetry.trustedOnly" = "Partilhar apenas com contactos de confiança"; + +/* Description for trusted contacts toggle */ +"telemetry.trustedOnlyDescription" = "Limitar a telemetria aos contactos selecionados"; + +/* Link to manage trusted contacts */ +"telemetry.manageTrusted" = "Gerir contactos de confiança"; + +/* Footer explaining telemetry */ +"telemetry.footer" = "Quando ativado, outros nodos podem pedir os dados de telemetria do dispositivo."; + +// MARK: - WiFi Section + +/* Section header for WiFi settings */ +"wifi.header" = "Wi-Fi"; + +/* Label for IP address */ +"wifi.address" = "Endereço"; + +/* Label for port number */ +"wifi.port" = "Porta"; + +/* Button to edit WiFi connection */ +"wifi.editConnection" = "Editar ligação"; + +/* Footer explaining WiFi address */ +"wifi.footer" = "Endereço de rede local do dispositivo"; + +// MARK: - WiFi Edit Sheet + +/* Navigation title for WiFi edit sheet */ +"wifiEdit.title" = "Editar ligação Wi-Fi"; + +/* Section header for connection details */ +"wifiEdit.connectionDetails" = "Detalhes da ligação"; + +/* Placeholder for IP address field */ +/* Placeholder for port field */ +/* Accessibility label for clear IP button */ +/* Accessibility label for clear port button */ +/* Footer explaining reconnection */ +"wifiEdit.footer" = "Alterar estes valores irá desligar e voltar a ligar ao novo endereço."; + +/* Text shown while reconnecting */ +"wifiEdit.reconnecting" = "A religar…"; + +/* Button to save changes */ +"wifiEdit.saveChanges" = "Guardar alterações"; + +/* Error for invalid port */ +"wifiEdit.error.invalidPort" = "Número de porta inválido"; + +// MARK: - Error Alert + +/* Alert title for generic errors */ +"alert.error.title" = "Erro"; + +// MARK: - Retry Alert + +/* Alert title when max retries exceeded */ +"alert.retry.unableToSave" = "Não foi possível guardar a definição"; + +/* Alert title for connection errors */ +"alert.retry.connectionError" = "Erro de ligação"; + +/* Button to retry the operation */ +"alert.retry.retry" = "Tentar novamente"; + +/* Alert message when max retries exceeded */ +"alert.retry.ensureConnected" = "Certifique-se de que o dispositivo está ligado."; + +/* Fallback message for retry alerts when error description is unavailable */ +"alert.retry.fallbackMessage" = "Certifique-se de que o dispositivo está ligado e tente novamente."; + +// MARK: - Battery Curve Chart + +/* Chart X axis label */ +"chart.percent" = "Percentagem"; + +/* Chart Y axis label */ +"chart.voltage" = "Tensão (V)"; + +/* Accessibility label for battery curve chart */ +"chart.accessibility" = "Curva de descarga da bateria com a tensão em cada nível de percentagem"; + +// MARK: - Messages Settings Section + +/* Section header for message info settings in chats */ +"messages.header" = "Informações da mensagem"; + +/* Toggle label for showing the raw uncorrected wire send time on incoming messages */ +/* Toggle label for showing routing path on incoming messages */ +"messages.showIncomingPath" = "Caminho de entrada"; + +/* Toggle label for showing hop count on incoming messages */ +"messages.showIncomingHopCount" = "Número de saltos de entrada"; + +/* Toggle label for showing the radio region an incoming message was flooded under */ +"messages.showIncomingRegion" = "Região de entrada"; + +/* Footer explaining what the message display options show */ +"messages.footer" = "Mostra informações de routing e de tempo nos balões das mensagens recebidas."; + +// MARK: - Direct Messages Settings Section + +/* Section header for direct message settings */ +"directMessages.header" = "DMs"; + +/* Picker label for number of acknowledgments */ +"directMessages.acknowledgments" = "Confirmações"; + +/* Footer explaining the acknowledgments setting */ +"directMessages.footer" = "Número de confirmações enviadas por DM. Use 2 para melhor confirmação de entrega em ligações pouco fiáveis."; + +// MARK: - BLE Status Indicator + +/* Menu item to send a zero-hop advertisement */ +"bleStatus.sendZeroHopAdvert" = "Enviar Advert zero-hop"; + +/* Menu item to send a flood advertisement */ +"bleStatus.sendFloodAdvert" = "Enviar Advert flood"; + +/* Menu item to change the connected device */ +"bleStatus.changeDevice" = "Mudar de dispositivo"; + +/* Menu item to disconnect from the current device */ +"bleStatus.disconnect" = "Desligar"; + +/* Status shown when device is disconnected */ +"bleStatus.status.disconnected" = "Desligado"; + +/* Status shown when device is connecting */ +"bleStatus.status.connecting" = "A ligar…"; + +/* Status shown when device is connected but not ready */ +"bleStatus.status.connected" = "Ligado"; + +/* Status shown when device is syncing data */ +"bleStatus.status.syncing" = "A sincronizar"; + +/* Status shown when device is ready */ +"bleStatus.status.ready" = "Pronto"; + +/* Accessibility label for BLE status indicator */ +"bleStatus.accessibilityLabel" = "Estado da ligação Bluetooth"; + +/* Accessibility hint when disconnected */ +"bleStatus.accessibilityHint.disconnected" = "Toque duas vezes para ligar o dispositivo"; + +/* Accessibility hint when connected */ +"bleStatus.accessibilityHint.connected" = "Mostra as opções de ligação do dispositivo"; + +/* Accessibility hint for zero-hop advert button */ +"bleStatus.sendZeroHopAdvert.hint" = "Transmite apenas para vizinhos diretos"; + +/* Accessibility hint for flood advert button */ +"bleStatus.sendFloodAdvert.hint" = "Propaga o Advert por toda a mesh"; + +/* Label shown in BLE status menu when repeat mode is active */ +"bleStatus.repeatModeActive" = "Modo de repetidor ativo"; + +// MARK: - Config Export/Import + +/* Section title for config export/import */ +"configExport.sectionTitle" = "Configuração do dispositivo"; + +/* Section footer explaining config export/import */ +"configExport.sectionFooter" = "Exporte ou importe definições do dispositivo, canais e contactos como um ficheiro JSON."; + +/* Export navigation row label */ +"configExport.export" = "Exportar config"; + +/* Export screen navigation title */ +"configExport.title" = "Exportar configuração"; + +/* Export full config button */ +"configExport.exportFull" = "Exportar configuração completa"; + +/* Export selected sections button */ +"configExport.exportSelected" = "Exportar secções selecionadas"; + +/* Customize export disclosure label */ +/* Select all toggle label */ +"configExport.selectAll" = "Selecionar tudo"; + +/* Toggle labels for export sections */ +"configExport.nodeIdentity" = "Identidade do nodo (nome e chaves)"; +"configExport.nodeIdentity.description" = "Nome do dispositivo, chave pública e chave privada"; +"configExport.radioSettings" = "Definições do rádio"; +"configExport.radioSettings.description" = "Frequência, largura de banda, fator de espalhamento, taxa de codificação, potência de TX"; +"configExport.positionSettings" = "Posição"; +"configExport.positionSettings.description" = "Latitude e longitude"; +"configExport.otherSettings" = "Outras definições"; +"configExport.otherSettings.description" = "Permissões de contactos, partilha de localização"; +"configExport.channels" = "Canais"; +"configExport.channels.description" = "Todos os canais e as respetivas chaves de encriptação"; +"configExport.contacts" = "Contactos"; +"configExport.contacts.description" = "Nomes, chaves e últimas posições conhecidas"; + +// MARK: - Config Import + +/* Import screen navigation title */ +"configImport.title" = "Importar configuração"; + +/* Select file button */ +"configImport.selectFile" = "Selecionar ficheiro JSON"; + +/* Confirmation alert title */ +"configImport.confirmTitle" = "Aplicar a configuração?"; + +/* Confirmation alert message (param: device name) */ +"configImport.confirmMessage" = "Isto irá fundir as definições selecionadas com %@. Os canais e contactos existentes serão atualizados ou adicionados."; + +/* Apply button label */ +"configImport.applyButton" = "Aplicar ao dispositivo"; + +/* Apply button label (additive only — channels/contacts) */ +"configImport.applyButtonAdd" = "Adicionar ao dispositivo"; + +/* Apply button label (overwrite settings) */ +"configImport.applyButtonOverwrite" = "Substituir definições"; + +/* Confirmation alert title (additive only) */ +"configImport.confirmTitleAdd" = "Adicionar ao dispositivo?"; + +/* Confirmation alert title (overwrite settings) */ +"configImport.confirmTitleOverwrite" = "Substituir as definições?"; + +/* Confirmation alert message (additive only, param: device name) */ +"configImport.confirmMessageAdd" = "Isto irá adicionar canais e contactos a %@. Os dados existentes não serão removidos."; + +/* Confirmation alert message (overwrite only, param: device name) */ +"configImport.confirmMessageOverwrite" = "Isto irá substituir as definições selecionadas (%@). Os valores atuais serão substituídos."; + +/* Confirmation alert message (mixed, param: device name) */ +"configImport.confirmMessageMixed" = "Isto irá substituir as definições e adicionar canais/contactos (%@)."; + +/* Proximity warning */ +"configImport.proximityWarning" = "Mantenha os dispositivos próximos durante a importação"; + +/* Import cancelled */ +"configImport.cancelled" = "A importação foi cancelada."; + +/* Import cancelled after one or more settings were already written to the device */ +"configImport.cancelledPartial" = "Importação cancelada, mas algumas definições já tinham sido gravadas no dispositivo. Reveja a configuração do dispositivo."; + +/* Import failed after one or more settings were already written; %@ is the underlying error message */ +"configImport.failedPartial" = "%@ Algumas definições podem já ter sido gravadas no dispositivo. Reveja a configuração do dispositivo."; + +/* Import success */ +"configImport.importSuccess" = "Configuração importada com êxito"; + +/* Private key warning */ +"configImport.privateKeyWarning" = "Isto irá substituir a identidade criptográfica do nodo"; + +"configImport.current" = "Atual: %@"; +"configImport.new" = "Novo: %@"; + +/* Channel count (param: integer) */ +"configImport.channelCount" = "Irá adicionar/atualizar %d canais no dispositivo"; + +/* Import navigation row label */ +"configImport.importConfig" = "Importar config"; + +/* Radio settings change warning */ +"configImport.radioWarning" = "Alterar as definições de rádio irá desligar da rede mesh atual"; + +/* Contact count (param: integer) */ +"configImport.contactCount" = "Irá adicionar/atualizar %d contactos no dispositivo"; + +/* Generic device-name fallback used in confirmation messages when the node has no name */ +"configImport.thisDevice" = "este dispositivo"; + +/* Import progress: applying position */ +"configImport.stepPosition" = "A definir a posição"; + +/* Import progress: applying other parameters */ +"configImport.stepOtherParameters" = "A definir outros parâmetros"; + +/* Import progress: importing the private key */ +"configImport.stepPrivateKey" = "A importar a chave privada"; + +/* Import progress: setting the node name */ +"configImport.stepNodeName" = "A definir o nome do nodo"; + +/* Import progress: applying radio parameters */ +"configImport.stepRadioParameters" = "A definir os parâmetros do rádio"; + +/* Import progress: setting TX power */ +"configImport.stepTxPower" = "A definir a potência de TX"; + +/* Import progress: importing a channel (param: channel name) */ +"configImport.stepChannel" = "A importar o canal: %@"; + +/* Import progress: importing a contact (param: contact name) */ +"configImport.stepContact" = "A importar o contacto: %@"; + +/* Validation field label: radio frequency */ +"configImport.field.frequency" = "Frequência"; + +/* Validation field label: radio bandwidth */ +"configImport.field.bandwidth" = "Largura de banda"; + +/* Validation field label: spreading factor */ +"configImport.field.spreadingFactor" = "Fator de espalhamento"; + +/* Validation field label: coding rate */ +"configImport.field.codingRate" = "Taxa de codificação"; + +/* Validation field label: TX power */ +"configImport.field.txPower" = "Potência de TX"; + +/* Validation field label: latitude */ +"configImport.field.latitude" = "Latitude"; + +/* Validation field label: longitude */ +"configImport.field.longitude" = "Longitude"; + +/* Validation error: value outside radio's supported range (param: field label) */ +"configImport.error.radioOutOfRange" = "%@ está fora do intervalo suportado pelo rádio"; + +/* Validation error: invalid coordinate (param: field label) */ +"configImport.error.positionInvalid" = "%@ tem uma coordenada inválida ou fora do intervalo"; + +/* Validation error: contact coordinate invalid (params: contact name, field label) */ +"configImport.error.contactCoordinateInvalid" = "O contacto \"%@\" %@ tem uma coordenada inválida ou fora do intervalo"; + +/* Validation error: contact has an invalid routing path (param: contact name) */ +"configImport.error.invalidOutPath" = "O contacto \"%@\" tem um caminho de routing inválido"; + +/* Validation error: not enough free contact slots (params: needed count, available count) */ +"configImport.error.contactCapacityExceeded" = "A importação precisa de %d slot(s) de contacto livre(s), mas restam apenas %d no dispositivo"; + +/* Validation error: channel secret wrong length (params: channel index, hex char count) */ +"configImport.error.invalidChannelSecret" = "O canal %1$lld tem um secret inválido (%2$lld caracteres hex, esperados 32)"; + +/* Validation error: contact public key malformed (param: contact name) */ +"configImport.error.invalidContactPublicKey" = "O contacto \"%@\" tem uma chave pública inválida"; + +/* Validation error: unsupported path hash mode (params: contact name, mode value) */ +"configImport.error.invalidPathHashMode" = "O contacto \"%1$@\" tem um modo de hash do caminho não suportado %2$lld (esperado 0, 1 ou 2)"; + +/* Validation error: private key wrong length (params: hex char count, expected hex char count) */ +"configImport.error.invalidPrivateKey" = "Chave privada inválida (%1$lld caracteres hex, esperados %2$lld)"; + +/* Validation error: no empty channel slot left (param: channel name) */ +"configImport.error.noAvailableChannelSlot" = "Nenhum slot de canal vazio disponível para \"%@\""; + +// MARK: - Blocking Section + +/* Location: BlockingSection.swift - Purpose: Section header */ +"blocking.header" = "Bloqueio"; + +/* Location: BlockingSection.swift - Purpose: Channel senders row */ +"blocking.channelSenders" = "Remetentes de canal"; + +/* Location: BlockingSection.swift - Purpose: Contacts row */ +"blocking.contacts" = "Contactos"; + +/* Location: BlockedChannelSendersView.swift - Purpose: Navigation title */ +"blocking.channelSenders.title" = "Remetentes de canal bloqueados"; + +/* Location: BlockedChannelSendersView.swift - Purpose: Empty state title */ +"blocking.channelSenders.empty.title" = "Nenhum utilizador bloqueado"; + +/* Location: BlockedChannelSendersView.swift - Purpose: Empty state description */ +"blocking.channelSenders.empty.description" = "Os nomes de remetentes de canal que bloquear aparecem aqui."; + +/* Location: BlockedChannelSendersView.swift - Purpose: Swipe action to unblock */ +// MARK: - Language + +/* Location: SettingsView.swift - Purpose: Language row title */ +"language.title" = "Idioma"; + +// MARK: - Region + +/* Location: RegionSettingsView.swift - Title */ +/* Location: SettingsView.swift - Detail row when region is unset */ +/* Location: RegionalAreas.subdivisionDisplayName - California (US-CA) state name */ +"region.subdivision.US-CA" = "Califórnia"; + +/* Location: RegionalAreas.subdivisionDisplayName - Queensland (AU-QLD) state name */ +"region.subdivision.AU-QLD" = "Queensland"; + +/* Location: RegionalAreas.subdivisionDisplayName - South Australia (AU-SA) state name */ +"region.subdivision.AU-SA" = "Austrália do Sul"; + +/* Location: RegionalAreas.subdivisionDisplayName - Western Australia (AU-WA) state name */ +"region.subdivision.AU-WA" = "Austrália Ocidental"; + +// MARK: - Live Activity + +/* Label for the Live Activity toggle in App Settings */ +"liveActivity.title" = "Atividade em tempo real"; + +/* Title for the Live Activity tip shown after first connection */ +"liveActivity.tip.title" = "Estado do rádio num relance"; + +/* Message for the Live Activity tip */ +"liveActivity.tip.message" = "A ligação, a bateria e as mensagens ficam visíveis — mesmo sem abrir a app."; + +// MARK: - Regenerate Identity + +/* Section header for regenerate identity */ +"regenerateIdentity.header" = "Identidade"; + +/* Button label to open regenerate identity sheet */ +"regenerateIdentity.title" = "Regenerar chave"; + +/* Navigation title for the regenerate identity sheet */ +"regenerateIdentity.sheet.title" = "Regenerar chave"; + +/* Explanation of what regenerating identity does */ +"regenerateIdentity.sheet.explanation" = "Gere um novo par de chaves Ed25519 para substituir a identidade atual do dispositivo. Todos os contactos terão de voltar a descobrir o dispositivo."; + +/* Button to generate a new key */ +"regenerateIdentity.generate" = "Gerar chave"; + +/* Button to generate another key after the first */ +/* Progress label while generating */ +"regenerateIdentity.generating" = "A gerar…"; + +/* Button to apply the generated key to the device */ +"regenerateIdentity.replace" = "Usar esta chave"; + +/* Progress label while importing key to device */ +"regenerateIdentity.importing" = "A importar…"; + +/* Disclosure group label for custom prefix */ +"regenerateIdentity.prefix.label" = "Prefixo personalizado"; + +/* Placeholder for prefix text field */ +"regenerateIdentity.prefix.placeholder" = "ex.: 2BA1"; + +/* Footer explaining vanity prefix */ +"regenerateIdentity.prefix.footer" = "Opcionalmente, especifique até 4 caracteres hex com os quais a chave pública deve começar. Prefixos mais longos demoram mais tempo."; + +/* Error when prefix is not 1–4 hex characters */ +/* Error when prefix is 00 or FF */ +"regenerateIdentity.prefix.error.reserved" = "00 e FF são reservados e não podem ser usados"; + +/* Alert title for replace identity confirmation */ +"regenerateIdentity.alert.replace.title" = "Substituir a identidade?"; + +/* Alert confirm button for replace identity */ +"regenerateIdentity.alert.replace.confirm" = "Substituir"; + +/* Alert message for replace identity */ +"regenerateIdentity.alert.replace.message" = "Isto irá substituir permanentemente a identidade do dispositivo. Os outros dispositivos irão vê-lo como um contacto novo."; + +/* Error when firmware does not support key import */ +"regenerateIdentity.error.featureDisabled" = "A importação de chaves não é suportada por esta versão de firmware"; + +/* Error when device rejects the key */ +"regenerateIdentity.error.deviceRejected" = "O dispositivo rejeitou a chave. Tente novamente."; + +// MARK: - Import Key + +/* Button label to open import key sheet */ +"importKey.title" = "Importar chave"; + +/* Navigation title for the import key sheet */ +"importKey.sheet.title" = "Importar chave"; + +/* Explanation of what importing a key does */ +"importKey.sheet.explanation" = "Cole uma chave privada Ed25519 existente (128 caracteres hex) para substituir a identidade atual do dispositivo."; + +/* Label for the private key input field */ +"importKey.keyInput.label" = "Chave privada"; + +/* Placeholder for the key input field */ +"importKey.keyInput.placeholder" = "Cole a chave hex de 128 caracteres"; + +/* Button to import the key */ +"importKey.import" = "Importar chave"; + +/* Progress label while importing */ +"importKey.importing" = "A importar…"; + +/* Error when hex input is wrong length or invalid */ +"importKey.error.invalidHex" = "A chave tem de ter exatamente 128 caracteres hex"; + +/* Error when key fails Ed25519 validation */ +"importKey.error.invalidKey" = "Não é uma chave privada Ed25519 válida"; + +// Reply with Quote +"replyWithQuote.toggle" = "Responder com citação"; +"replyWithQuote.footer" = "A resposta inclui uma pré-visualização da mensagem original."; + +// MARK: - Maps + +/* Navigation title for Settings → Maps hub */ +"maps.title" = "Mapas"; + +/* Section header for display preferences on Maps settings */ +"maps.displayHeader" = "Visualização"; + +/* Picker label for map basemap appearance */ +"maps.appearance" = "Aspeto do mapa"; + +/* Footer explaining map appearance is basemap-only */ +"maps.appearanceFooter" = "Controla apenas o mapa base. As cores da app continuam a seguir Aspeto."; + +/* Section header for Offline Maps entry under Maps settings */ +"maps.offlineHeader" = "Mapas offline"; + +// MARK: - Offline Maps + +/* Navigation title for offline maps settings */ +"offlineMaps.title" = "Mapas offline"; + +/* Title for empty state when no offline packs exist */ +"offlineMaps.emptyTitle" = "Nenhum mapa offline"; + +/* Description for empty state */ +"offlineMaps.emptyDescription" = "Transfira regiões do mapa para usar sem Internet."; + +/* Button to download a new offline region */ +"offlineMaps.downloadRegion" = "Transferir região"; + +/* Section header for storage info */ +"offlineMaps.storage" = "Armazenamento"; + +/* Label for total storage used */ +"offlineMaps.storageUsed" = "Armazenamento usado"; + +/* Fallback name for unknown region */ +"offlineMaps.unknownRegion" = "Região desconhecida"; + +/* Status when pack download is complete */ +"offlineMaps.complete" = "Transferido"; + +/* Status when pack is downloading */ +"offlineMaps.downloading" = "A transferir…"; + +/* Navigation title for region picker sheet */ +"offlineMaps.pickRegion" = "Selecionar região"; + +/* Placeholder for region name text field */ +"offlineMaps.regionName" = "Nome da região"; + +/* Button to start download */ +"offlineMaps.download" = "Transferir"; + +/* Cancel button */ +"offlineMaps.cancel" = "Cancelar"; + +/* Delete confirmation title */ +/* Delete confirmation message */ +/* Delete button */ +/* Error: insufficient disk space */ +"offlineMaps.error.insufficientDiskSpace" = "Não há espaço de armazenamento suficiente. São necessários pelo menos 100 MB."; + +/* Error: tile limit reached */ +"offlineMaps.error.tileLimitReached" = "Foi atingido o limite de mosaicos de transferência."; + +/* Pause download button */ +"offlineMaps.pause" = "Pausa"; + +/* Resume download button */ +"offlineMaps.resume" = "Retomar"; + +/* Paused status label */ +"offlineMaps.paused" = "Em pausa"; + +/* Estimated download size */ +"offlineMaps.estimatedSize" = "Tamanho estimado: ~%@"; + +/* Large tile download warning */ +"offlineMaps.largeTileWarning" = "Área de transferência grande. Isto pode demorar e usar bastante armazenamento."; + +/* Hint shown before estimate is available */ +"offlineMaps.downloadHint" = "Introduza um nome e selecione uma área para transferir."; + +/* Download exceeds available storage */ +"offlineMaps.exceedsStorage" = "Não há armazenamento suficiente neste dispositivo. Aproxime o zoom para selecionar uma área menor."; + +/* Layer type labels */ +"offlineMaps.layer.base" = "Mapa base"; +"offlineMaps.layer.topo" = "Topografia"; + +/* Layers section header */ +"offlineMaps.layers" = "Camadas"; + +/* Include layers prompt */ +/* No network available */ +"offlineMaps.noNetwork" = "É necessária uma ligação à Internet para transferir mapas."; + +/* Storage section footer */ +"offlineMaps.storageFooter" = "Inclui dados do mapa e índices internos. O total pode ser maior do que a soma das transferências individuais."; + +// MARK: - Backup & Restore + +/* Navigation title for backup & restore screen */ +"settings.backup.title" = "Backup e restauro"; + +/* Section header for file backup */ +"settings.backup.file_backup.header" = "Backup em ficheiro"; + +/* Section footer for file backup */ +"settings.backup.file_backup.footer" = "Exporte ou restaure mensagens, contactos, canais, caminhos guardados e definições. A configuração do rádio é lida do dispositivo em cada ligação."; + +/* Export row title */ +"settings.backup.export.title" = "Exportar dados da app"; + +/* Export row subtitle */ +"settings.backup.export.subtitle" = "Guardar num ficheiro .mc1backup"; + +/* Import row title */ +"settings.backup.import.title" = "Importar dados da app"; + +/* Import row subtitle */ +"settings.backup.import.subtitle" = "Restaurar a partir de um ficheiro .mc1backup"; + +/* Footer shown above the backup section when a radio is connected, explaining why import is disabled */ +"settings.backup.import.disabled_when_connected" = "Termine a ligação ao rádio antes de importar um backup."; + +/* Export confirmation alert title */ +"settings.backup.export.alert.title" = "Aviso de segurança"; + +/* Export confirmation alert message */ +"settings.backup.export.alert.message" = "Este backup inclui as chaves de encriptação dos canais, o histórico de mensagens, a lista de contactos e o endereço de rede local e a porta do nodo Wi-Fi. Guarde o ficheiro exportado de forma segura."; + +/* Export confirmation alert export button */ +"settings.backup.export.alert.export" = "Exportar"; + +/* Export confirmation alert cancel button */ +"settings.backup.export.alert.cancel" = "Cancelar"; + +/* Export progress label */ +"settings.backup.export.progress" = "A preparar o backup…"; + +/* Import preview navigation title */ +"settings.backup.import.preview.title" = "Pré-visualização da importação"; + +/* Import preview section header: backup details */ +"settings.backup.import.preview.details" = "Detalhes do backup"; + +/* Import preview label: export date */ +"settings.backup.import.preview.exported" = "Data de exportação"; + +/* Import preview label: app version */ +"settings.backup.import.preview.app_version" = "Versão da app"; + +/* Import preview section header: contents */ +"settings.backup.import.preview.contents" = "Conteúdo"; + +/* Import preview manifest label: messages */ +"settings.backup.import.preview.messages" = "Mensagens"; + +/* Import preview manifest label: contacts */ +"settings.backup.import.preview.contacts" = "Contactos"; + +/* Import preview manifest label: channels */ +"settings.backup.import.preview.channels" = "Canais"; + +/* Import preview manifest label: devices */ +"settings.backup.import.preview.devices" = "Dispositivos"; + +/* Import preview manifest label: room messages */ +"settings.backup.import.preview.room_messages" = "Mensagens de sala"; + +/* Import preview manifest label: reactions */ +"settings.backup.import.preview.reactions" = "Reações"; + +/* Import preview manifest label: saved paths */ +"settings.backup.import.preview.saved_paths" = "Caminhos guardados"; + +/* Import preview manifest label: remote node sessions */ +"settings.backup.import.preview.remote_node_sessions" = "Sessões de nodos remotos"; + +/* Import preview info text */ +"settings.backup.import.preview.info" = "Apenas os dados novos são adicionados; os registos existentes não são substituídos. Os contactos eliminados depois de este backup ter sido criado voltam a aparecer, e quaisquer estados de bloqueio, silenciado ou favorito do backup são reaplicados."; + +/* Import preview import button */ +"settings.backup.import.preview.button" = "Importar dados"; + +/* Import progress label */ +"settings.backup.import.progress" = "A importar dados…"; +/* Import parsing label */ +"settings.backup.import.parsing" = "A ler o backup…"; +/* Import cancelling label shown after the user taps Cancel during an active import */ +"settings.backup.import.cancelling" = "A cancelar…"; +/* Import preview cancel / dismiss toolbar button */ +"settings.backup.import.preview.cancel" = "Cancelar"; + +/* Import success title */ +"settings.backup.import.success.title" = "Importação concluída"; + +/* Import success section header: items that were newly added to this device */ +"settings.backup.import.success.added_section" = "Adicionado a este dispositivo"; + +/* Import success section header: items in the backup that were already on this device */ +"settings.backup.import.success.already_here_section" = "Já neste dispositivo"; + +/* Import success section footer: always visible under the "Already on this device" section */ +"settings.backup.import.success.already_here_footer" = "Os dados existentes do dispositivo foram mantidos tal como estavam; nada foi substituído."; + +/* Import success hero subtitle when new items were added (pluralized in .stringsdict) */ +"settings.backup.import.success.subtitle_added" = "%d itens adicionados."; + +/* Import success hero subtitle when only existing items were refreshed (pluralized in .stringsdict) */ +"settings.backup.import.success.subtitle_refreshed" = "%d itens existentes atualizados."; + +/* Import success disclosure row summary for the "Already on this device" section (pluralized in .stringsdict) */ +"settings.backup.import.success.already_here_summary" = "%d itens já presentes"; + +/* Import success footer line appended when some of the already-here items had metadata refreshed (pluralized in .stringsdict) */ +"settings.backup.import.success.already_here_refreshed" = "%d destes foram atualizados com informações mais recentes do backup."; + +/* Import success section header: channels that had no free local slot and couldn't be restored */ +"settings.backup.import.success.dropped_section" = "Não foi possível restaurar"; + +/* Import success disclosure row summary for the "Couldn't be restored" section */ +"settings.backup.import.success.dropped_summary" = "Não foi possível restaurar %d itens"; + +/* Import success footer explaining why some channels couldn't be restored and how to recover them */ +"settings.backup.import.success.dropped_footer" = "Estes canais não tinham slot livre neste rádio, por isso eles e as respetivas mensagens não foram importados. Liberte um slot de canal e importe novamente para os restaurar."; + +/* Import success footer when only discovered nodes exceeded the per-radio discover-list cap */ +"settings.backup.import.success.dropped_footer_discovered_nodes" = "Estes nodos descobertos não puderam ser restaurados porque a lista de descoberta deste rádio está cheia (%d nodos). Os nodos locais existentes foram mantidos; a mesh voltará a emitir Adverts dos que faltam."; + +/* Import success footer when both channel slots and the discover-list cap caused drops */ +"settings.backup.import.success.dropped_footer_mixed" = "Alguns itens não puderam ser restaurados: canais sem slot livre no rádio e nodos descobertos que excederam a capacidade da lista de descoberta deste rádio (%d nodos). Liberte um slot de canal e importe novamente os canais; a mesh voltará a emitir Adverts dos nodos descobertos em falta."; + +/* Import success done button */ +"settings.backup.import.success.done" = "OK"; + +/* Import failure title */ +"settings.backup.import.error.title" = "Falha na importação"; + +/* Import error dismiss button */ +"settings.backup.import.error.dismiss" = "Fechar"; + +/* Import cancelled title shown after user cancels an import before it commits */ +"settings.backup.import.cancelled.title" = "Importação cancelada"; + +/* Import cancelled message — rollback ran and nothing on device was changed */ +"settings.backup.import.cancelled.message" = "Nenhum dado foi alterado."; + +/* File access error */ +/* Import preview manifest label: blocked senders */ +"settings.backup.import.preview.blocked_senders" = "Remetentes bloqueados"; + +/* Import preview manifest label: node status snapshots */ +"settings.backup.import.preview.node_status_snapshots" = "Instantâneos de nodos"; + +/* Import preview manifest label: discovered nodes */ +"settings.backup.import.preview.discovered_nodes" = "Nodos descobertos"; + +/* Import preview manifest label: message repeats */ +"settings.backup.import.preview.message_repeats" = "Repetições de mensagens"; + +/* Import header title when the backup had nothing new to add (every record was skipped) */ +"settings.backup.import.nothing_to_import.title" = "Já está tudo aqui"; + +/* Import subtitle when the backup had nothing new to add */ +"settings.backup.import.nothing_to_import.subtitle" = "O conteúdo deste backup já está neste dispositivo."; + +/* Default filename for an exported backup; %@ is an ISO-style timestamp */ +"settings.backup.export.default_filename" = "Backup MC1 %@.mc1backup"; + +/* Backup error: file is corrupt or unreadable */ +"settings.backup.error.invalid_file" = "O ficheiro de backup é inválido ou não pôde ser lido."; + +/* Backup error: file exceeds parser size cap; %1$d actual MB, %2$d max MB */ +"settings.backup.error.file_too_large" = "O ficheiro de backup é demasiado grande para importar (%1$d MB; o limite é %2$d MB)."; + +/* Backup error: decompressed size exceeds safety cap; %d is max MB uncompressed */ +"settings.backup.error.decompressed_too_large" = "O ficheiro de backup ultrapassa o limite seguro de tamanho depois de descomprimido (%d MB sem compressão)."; + +/* Backup error: file version too new; %1$d found, %2$d max supported */ +"settings.backup.error.unsupported_version" = "Este backup foi criado com um formato mais recente (versão %1$d). Esta app oferece suporte até à versão %2$d. Atualize a app e tente novamente."; + +/* Backup error: manifest counts do not match arrays */ +"settings.backup.error.corrupted_manifest" = "O ficheiro de backup parece estar corrompido. As quantidades declaradas não correspondem aos dados reais."; + +/* Backup error: export failed; %@ is underlying error */ +"settings.backup.error.export_failed" = "Não foi possível criar o backup: %@"; + +/* Backup error: import failed; %@ is underlying error */ +"settings.backup.error.import_failed" = "Não foi possível importar o backup: %@"; + +// MARK: - Export success + +/* Hero title on the backup export success sheet */ +"settings.backup.export.success.title" = "Backup guardado"; + +/* Section header listing record counts included in the export */ +"settings.backup.export.success.included_section" = "Incluído no backup"; + +/* Primary button on the export success sheet */ +"settings.backup.export.success.done" = "OK"; + +/* VoiceOver announcement when the export success sheet appears; %@ is the filename */ +"settings.backup.export.success.announcement" = "Backup guardado como %@"; + +/* Error shown when applying a theme the user does not own */ +"Support.Error.ThemeNotOwned" = "Não possui este tema."; + +/* VoiceOver announcement when a refund reverts the active theme to the default */ +"Appearance.Accessibility.ThemeReverted" = "Tema reposto para o predefinido."; + +/* MARK: Support (purchase) screen */ +"Support.Title" = "Apoiar o desenvolvimento"; +"Support.Header.Body" = "Se gosta do MC1, eis algumas formas de apoiar o desenvolvimento. Tudo aqui é opcional: alguns donativos e alguns temas extra."; +"Support.Themes.Title" = "Temas"; +"Support.Themes.Owned" = "Adquirido"; +"Support.Themes.PurchasedFooter" = "Aplique o novo tema em Definições → Aspeto."; +"Support.Themes.AllUnlocked" = "Todos os temas desbloqueados!"; +"Support.Bundle.Title" = "Todos os temas"; +"Support.Bundle.Subtitle" = "Inclui quaisquer temas adicionados mais tarde."; +"Support.Contributions.Title" = "Donativos"; +"Support.Contributions.Footer" = "Os donativos são um agradecimento e apoiam o desenvolvimento do MC1. Não incluem bens, serviços nem benefícios de associação."; +"Support.Contributions.HighValueConfirm" = "Enviar %@?"; +"Support.Contributions.ConfirmButton" = "Contribuir %@"; +"Support.Restore.Button" = "Restaurar compras"; +"Support.Restore.Syncing" = "A restaurar…"; +"Support.Refund.Link" = "Pedir um reembolso"; +"Support.Pending.Banner" = "A aguardar aprovação. A compra é desbloqueada assim que for aprovada."; +"Support.Pending.Button" = "A aguardar aprovação"; +"Support.Contact.Link" = "Enviar e-mail ao programador"; +"Support.ThankYou.Title" = "Obrigado!"; +"Support.ThankYou.Body" = "Obrigado pelo apoio, significa muito. Vou continuar a trabalhar para melhorar o MC1!"; + +/* MARK: Feedback screen */ +"Feedback.Title" = "Feedback"; +"Feedback.Header.Body" = "GitHub Issues é o método preferido, mas o e-mail também serve."; +"Feedback.GitHub.Link" = "Abrir um GitHub Issue"; +"Feedback.Email.Link" = "Enviar e-mail ao programador"; + +/* MARK: Theme display names */ +"Support.Theme.Default" = "Predefinido"; +"Support.Theme.Ember" = "Ember"; +"Support.Theme.Fern" = "Fern"; +"Support.Theme.Marine" = "Marine"; +"Support.Theme.Olive" = "Olive"; +"Support.Theme.Lavender" = "Lavender"; + +/* MARK: Support error messages (mapped from StoreServiceError) */ +"Support.Error.ProductsNotLoaded" = "Não foi possível carregar os produtos."; +"Support.Error.ProductNotFound" = "Produto não encontrado."; +"Support.Error.PurchaseFailed" = "A compra falhou: %@"; +"Support.Error.NetworkUnavailable" = "Rede indisponível."; +"Support.Error.VerificationFailed" = "Não foi possível verificar a compra."; +"Support.Error.NotEntitled" = "As compras estão restringidas neste dispositivo. Verifique as restrições de conteúdo em Tempo de Ecrã."; +"Support.Error.StorefrontUnavailable" = "Este produto não está disponível na sua região."; +"Support.Error.Unsupported" = "As compras na app não são suportadas neste dispositivo."; + +/* MARK: Support accessibility (label = identity+state, hint = action) */ +"Support.Accessibility.ThemeCard.LockedLabel" = "Tema %@, bloqueado"; +"Support.Accessibility.ThemeCard.OwnedLabel" = "Tema %@, adquirido"; +"Support.Accessibility.BundleCard.LockedLabel" = "Pacote Todos os temas, bloqueado, %@"; +"Support.Accessibility.BundleCard.LockedHint" = "Compra o pacote Todos os temas"; +"Support.Accessibility.BundleCard.LoadingLabel" = "Pacote Todos os temas, a carregar o preço"; +"Support.Accessibility.ContributionRow.Label" = "%1$@, %2$@"; +"Support.Accessibility.ContributionRow.Hint" = "Envia um donativo de agradecimento"; +"Support.Accessibility.TipConfirmAnnouncement" = "Obrigado pelo donativo."; + +/* MARK: Appearance (selection) screen */ +"Appearance.Title" = "Aspeto"; +"Appearance.Scheme.Header" = "Modo claro/escuro"; +"Appearance.Scheme.System" = "Sistema"; +"Appearance.Scheme.Light" = "Claro"; +"Appearance.Scheme.Dark" = "Escuro"; +"Appearance.Themes.Header" = "Escolha um tema"; +"Appearance.Themes.Selected" = "Selecionado"; +"Appearance.MoreThemes.Link" = "Comprar mais temas"; + +/* MARK: Appearance accessibility */ +"Appearance.Accessibility.ThemeCard.OwnedLabel" = "Tema %@, adquirido"; +"Appearance.Accessibility.ThemeCard.OwnedHint" = "Aplica este tema"; +"Appearance.Accessibility.ThemeCard.SelectedLabel" = "Tema %@, selecionado neste momento"; +"Appearance.Accessibility.Swatch.DualMode" = "%@, variantes clara e escura"; +"Appearance.Accessibility.Swatch.DarkOnly" = "%@, apenas escuro"; diff --git a/MC1/Resources/Localization/pt.lproj/Settings.stringsdict b/MC1/Resources/Localization/pt.lproj/Settings.stringsdict new file mode 100644 index 000000000..247fac506 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Settings.stringsdict @@ -0,0 +1,126 @@ + + + + + + dangerZone.alert.removeUnfavorited.message + + NSStringLocalizedFormatKey + %#@nodeCount@ + nodeCount + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Isto irá eliminar permanentemente %d nodo não marcado como favorito do dispositivo e da app, juntamente com as respetivas mensagens. + other + Isto irá eliminar permanentemente %d nodos não marcados como favoritos do dispositivo e da app, juntamente com as respetivas mensagens. + + + dangerZone.alert.removeUnfavorited.partial + + NSStringLocalizedFormatKey + %2$#@totalNodes@ + totalNodes + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Removido %1$d de %2$d nodo. A ligação foi interrompida — toque novamente no botão para tentar de novo. + other + Removidos %1$d de %2$d nodos. A ligação foi interrompida — toque novamente no botão para tentar remover os nodos restantes. + + + settings.backup.import.success.subtitle_added + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item adicionado. + other + %d itens adicionados. + + + settings.backup.import.success.subtitle_refreshed + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item existente atualizado. + other + %d itens existentes atualizados. + + + settings.backup.import.success.already_here_summary + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item já presente + other + %d itens já presentes + + + settings.backup.import.success.already_here_refreshed + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d destes foi atualizado com informações mais recentes do backup. + other + %d destes foram atualizados com informações mais recentes do backup. + + + settings.backup.import.success.dropped_summary + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Não foi possível restaurar %d item + other + Não foi possível restaurar %d itens + + + + diff --git a/MC1/Resources/Localization/pt.lproj/Tools.strings b/MC1/Resources/Localization/pt.lproj/Tools.strings new file mode 100644 index 000000000..ebb921190 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/Tools.strings @@ -0,0 +1,866 @@ +/* + Tools.strings + MC1 + + European Portuguese (pt-PT) translation of tools and App Intents strings. + AI-translated - please verify with native speakers. +*/ + +// MARK: - Tools View + +/* Location: ToolsView.swift - Navigation title */ +"tools.title" = "Ferramentas"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.tracePath" = "Traçar caminho"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.lineOfSight" = "Linha de vista"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.rxLog" = "Registo RX"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.noiseFloor" = "Ruído de fundo"; + +/* Location: ToolsView.swift - Tool selection label */ +"tools.nodeDiscovery" = "Descobrir nodos"; + +/* Location: ToolsView.swift - Empty state when no tool selected */ +"tools.selectTool" = "Selecione uma ferramenta"; + +// MARK: - RX Log View + +/* Location: RxLogView.swift - Empty state title when listening */ +"tools.rxLog.listening" = "A escutar..."; + +/* Location: RxLogView.swift - Empty state description */ +"tools.rxLog.listeningDescription" = "Os pacotes RF aparecem aqui à medida que chegam."; + +/* Location: RxLogView.swift - Disconnected state title */ +"tools.rxLog.notConnected" = "Não ligado"; + +/* Location: RxLogView.swift - Disconnected state description */ +"tools.rxLog.notConnectedDescription" = "Ligue o rádio mesh para ver os pacotes RF."; + +/* Location: RxLogView.swift - Live status indicator */ +"tools.rxLog.live" = "Em direto"; + +/* Location: RxLogView.swift - Offline status indicator */ +"tools.rxLog.offline" = "Offline"; + +/* Location: RxLogView.swift - Packet count in header, %lld is count */ +"tools.rxLog.packetsCount" = "%lld pacotes"; + +/* Location: RxLogView.swift - Filter menu section header */ +"tools.rxLog.routeType" = "Tipo de rota"; + +/* Location: RxLogView.swift - Filter menu section header */ +"tools.rxLog.decryptStatus" = "Estado da desencriptação"; + +/* Location: RxLogView.swift - Filter button label */ +"tools.rxLog.filter" = "Filtrar"; + +/* Location: RxLogView.swift - Overflow menu button label */ +"tools.rxLog.more" = "Mais"; + +/* Location: RxLogView.swift - Group duplicates toggle */ +"tools.rxLog.groupDuplicates" = "Agrupar duplicados"; + +/* Location: RxLogView.swift - Delete logs button */ +"tools.rxLog.deleteLogs" = "Eliminar registos"; + +/* Location: RxLogView.swift - Delete confirmation dialog title */ +"tools.rxLog.deleteConfirmation" = "Eliminar todos os registos?"; + +/* Location: RxLogView.swift - Delete confirmation button */ +"tools.rxLog.delete" = "Eliminar"; + +/* Location: RxLogView.swift - Direct route label */ +"tools.rxLog.direct" = "Direto"; + +/* Location: RxLogView.swift - Path detail for single hop */ +"tools.rxLog.hopSingular" = "salto"; + +/* Location: RxLogView.swift - Path detail for multiple hops */ +"tools.rxLog.hopPlural" = "saltos"; + +/* Location: RxLogView.swift - Signal strength accessibility label, %@ is quality */ +"tools.rxLog.signalStrength" = "Intensidade do sinal: %@"; + +/* Location: RxLogView.swift - Duplicate count accessibility label, %lld is count */ +"tools.rxLog.receivedTimes" = "Recebido %lld vezes"; + +/* Location: RxLogView.swift - Raw payload section title */ +"tools.rxLog.rawPayload" = "Payload em bruto"; + +/* Location: RxLogView.swift - Copy button */ +"tools.rxLog.copy" = "Copiar"; + +/* Location: RxLogView.swift - RSSI label */ +"tools.rxLog.rssiLabel" = "RSSI:"; + +/* Location: RxLogView.swift - SNR label */ +"tools.rxLog.snrLabel" = "SNR:"; + +/* Location: RxLogView.swift - Type label */ +"tools.rxLog.typeLabel" = "Tipo:"; + +/* Location: RxLogView.swift - Size label */ +"tools.rxLog.sizeLabel" = "Tamanho:"; + +/* Location: RxLogView.swift - Path label */ +"tools.rxLog.pathLabel" = "Caminho:"; + +/* Location: RxLogView.swift - Hash label */ +"tools.rxLog.hashLabel" = "Hash:"; + +/* Location: RxLogView.swift - From label */ +"tools.rxLog.fromLabel" = "De:"; + +/* Location: RxLogView.swift - To label */ +"tools.rxLog.toLabel" = "Para:"; + +/* Location: RxLogView.swift - Channel hash label */ +"tools.rxLog.channelHashLabel" = "Hash do canal:"; + +/* Location: RxLogView.swift - Channel name label */ +"tools.rxLog.channelNameLabel" = "Nome do canal:"; + +/* Location: RxLogView.swift - Region label */ +"tools.rxLog.regionLabel" = "Região:"; + +/* Location: RxLogView.swift - Unresolved region value */ +"tools.rxLog.regionUnresolved" = "Desconhecida"; + +/* Location: RxLogView.swift - Text label */ +"tools.rxLog.textLabel" = "Texto:"; + +/* Location: RxLogView.swift - Bytes suffix for size display */ +"tools.rxLog.bytes" = "bytes"; + +// MARK: - RX Log Filters + +/* Location: RxLogViewModel.swift - Route filter: all */ +"tools.rxLog.filter.all" = "Todos"; + +/* Location: RxLogViewModel.swift - Route filter: flood only */ +"tools.rxLog.filter.floodOnly" = "Só Flood"; + +/* Location: RxLogViewModel.swift - Route filter: direct only */ +"tools.rxLog.filter.directOnly" = "Só direto"; + +/* Location: RxLogViewModel.swift - Decrypt filter: decrypted */ +"tools.rxLog.filter.decrypted" = "Desencriptado"; + +/* Location: RxLogViewModel.swift - Decrypt filter: failed */ +"tools.rxLog.filter.failed" = "Falhou"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: not a decryptable packet */ +"tools.rxLog.decryptStatus.notApplicable" = "N/A"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: no stored channel key matches */ +"tools.rxLog.decryptStatus.noKey" = "Sem chave"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: HMAC validation failed */ +"tools.rxLog.decryptStatus.hmacFailed" = "HMAC falhou"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: AES decryption failed */ +"tools.rxLog.decryptStatus.decryptFailed" = "Falha na desencriptação"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: decrypted successfully */ +"tools.rxLog.decryptStatus.decrypted" = "Desencriptado"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: key found, decryption pending */ +"tools.rxLog.decryptStatus.hasKey" = "Com chave"; + +/* Location: DecryptStatus+Display.swift - Decrypt status: missing direct message key */ +"tools.rxLog.decryptStatus.noDmKey" = "Sem chave DM"; + +/* Location: RxLogRowView - Label for local device in path display */ +"tools.rxLog.pathYou" = "Eu"; + +/* Location: RxLogRowView - Route label for TRACE target hashes */ +"tools.rxLog.traceRouteLabel" = "Rota:"; + +// MARK: - Noise Floor View + +/* Location: NoiseFloorView.swift - Disconnected state description */ +"tools.noiseFloor.notConnectedDescription" = "Ligue o rádio mesh para medir o ruído de fundo."; + +/* Location: NoiseFloorView.swift - Collecting data title */ +"tools.noiseFloor.collectingData" = "A recolher dados..."; + +/* Location: NoiseFloorView.swift - Collecting data description */ +"tools.noiseFloor.collectingDataDescription" = "As leituras de ruído de fundo aparecem à medida que são recolhidas."; + +/* Location: NoiseFloorView.swift - Unit label */ +"tools.noiseFloor.dBm" = "dBm"; + +/* Location: NoiseFloorView.swift - Unit label for decibels */ +"tools.noiseFloor.dB" = "dB"; + +/* Location: NoiseFloorView.swift - No reading accessibility */ +"tools.noiseFloor.noReading" = "Nenhuma leitura disponível"; + +/* Location: NoiseFloorView.swift - Chart accessibility, %lld readings, %lld min, %lld max, %lld avg, %@ trend */ +"tools.noiseFloor.chartAccessibility" = "Histórico de ruído de fundo: %lld leituras, mínimo %lld dBm, máximo %lld dBm, média %lld dBm, tendência %@"; + +/* Location: NoiseFloorView.swift - Chart accessibility when empty */ +"tools.noiseFloor.chartAccessibilityEmpty" = "Gráfico do histórico de ruído de fundo, sem dados"; + +/* Location: NoiseFloorView.swift - Statistics section title */ +"tools.noiseFloor.statistics" = "Estatísticas"; + +/* Location: NoiseFloorView.swift - Minimum label */ +"tools.noiseFloor.minimum" = "Mínimo"; + +/* Location: NoiseFloorView.swift - Average label */ +"tools.noiseFloor.average" = "Média"; + +/* Location: NoiseFloorView.swift - Maximum label */ +"tools.noiseFloor.maximum" = "Máximo"; + +/* Location: NoiseFloorView.swift - Last RSSI label */ +"tools.noiseFloor.lastRssi" = "Último RSSI"; + +/* Location: NoiseFloorView.swift - Last SNR label */ +"tools.noiseFloor.lastSnr" = "Último SNR"; + +/* Location: NoiseFloorView.swift - Trend: stable */ +"tools.noiseFloor.trendStable" = "estável"; + +/* Location: NoiseFloorView.swift - Trend: increasing */ +"tools.noiseFloor.trendIncreasing" = "a aumentar"; + +/* Location: NoiseFloorView.swift - Trend: decreasing */ +"tools.noiseFloor.trendDecreasing" = "a diminuir"; + +// MARK: - Noise Floor Signal Quality + +/* Location: NoiseFloorViewModel.swift - Signal quality: excellent */ +"tools.noiseFloor.quality.excellent" = "Excelente"; + +/* Location: NoiseFloorViewModel.swift - Signal quality: good */ +"tools.noiseFloor.quality.good" = "Bom"; + +/* Location: NoiseFloorViewModel.swift - Signal quality: fair */ +"tools.noiseFloor.quality.fair" = "Razoável"; + +/* Location: NoiseFloorViewModel.swift - Signal quality: poor */ +"tools.noiseFloor.quality.poor" = "Fraco"; + +/* Location: NoiseFloorViewModel.swift - Signal quality: unknown */ +"tools.noiseFloor.quality.unknown" = "Desconhecido"; + +/* Location: NoiseFloorViewModel.swift - Error: device disconnected */ +"tools.noiseFloor.error.disconnected" = "Dispositivo desligado"; + +/* Location: NoiseFloorViewModel.swift - Error: unable to read stats */ +"tools.noiseFloor.error.unableToRead" = "Não foi possível ler as estatísticas do rádio"; + +// MARK: - Node Discovery View + +/* Location: NodeDiscoveryView.swift - Disconnected state description */ +"tools.nodeDiscovery.notConnectedDescription" = "Ligue o rádio mesh para descobrir %@."; + +/* Location: NodeDiscoveryView.swift - Initial empty state title */ +"tools.nodeDiscovery.scanPrompt" = "Procurar %@"; + +/* Location: NodeDiscoveryView.swift - Initial empty state description */ +"tools.nodeDiscovery.scanPromptDescription" = "Toque no botão abaixo para descobrir %@ nas proximidades."; + +/* Location: NodeDiscoveryView.swift - No results empty state title */ +"tools.nodeDiscovery.noResults" = "Sem resultados para %@"; + +/* Location: NodeDiscoveryView.swift - No results empty state description */ +"tools.nodeDiscovery.noResultsDescription" = "%@ não responderam ao ping de descoberta. Tente novamente ou aproxime-se da mesh."; + +/* Location: NodeDiscoveryView.swift - Scan button label (idle) */ +"tools.nodeDiscovery.scanButton" = "Procurar %@"; + +/* Location: NodeDiscoveryView.swift - Scan button label (scanning) */ +"tools.nodeDiscovery.stopButton" = "Parar pesquisa"; + +/* Location: NodeDiscoveryView.swift - Segment label */ +"tools.nodeDiscovery.repeaters" = "Repetidores"; + +/* Location: NodeDiscoveryView.swift - Segment label */ +"tools.nodeDiscovery.sensors" = "Sensores"; + +/* Location: NodeDiscoveryView.swift - Sort option */ +"tools.nodeDiscovery.sortSignal" = "Intensidade do sinal"; + +/* Location: NodeDiscoveryView.swift - Sort option */ +"tools.nodeDiscovery.sortName" = "Nome"; + +/* Location: NodeDiscoveryView.swift - Sort menu accessibility label */ +"tools.nodeDiscovery.sortMenu" = "Ordenar nodos"; + +/* Location: NodeDiscoveryView.swift - Sort menu accessibility hint */ +"tools.nodeDiscovery.sortMenuHint" = "Altera a ordem dos nodos descobertos"; + +/* Location: NodeDiscoveryRowView.swift - Unknown node name fallback */ +"tools.nodeDiscovery.unknownNode" = "Desconhecido"; + +/* Location: NodeDiscoveryRowView.swift - Node type: repeater */ +"tools.nodeDiscovery.typeRepeater" = "Repetidor"; + +/* Location: NodeDiscoveryRowView.swift - Node type: sensor */ +"tools.nodeDiscovery.typeSensor" = "Sensor"; + +/* Location: NodeDiscoveryRowView.swift - SNR label (local receive) */ +"tools.nodeDiscovery.snrDown" = "↓ %@ dB"; + +/* Location: NodeDiscoveryRowView.swift - SNR label (remote receive) */ +"tools.nodeDiscovery.snrUp" = "↑ %@ dB"; + +/* Location: NodeDiscoveryRowView.swift - RSSI label */ +"tools.nodeDiscovery.rssi" = "RSSI %@ dBm"; + +/* Location: NodeDiscoveryView.swift - Error alert title */ +"tools.nodeDiscovery.errorTitle" = "A pesquisa falhou"; + +// MARK: - Line of Sight View + +/* Location: LineOfSightView.swift - Map style: standard */ +/* Location: LineOfSightView.swift - Map style: terrain */ +/* Location: LineOfSightView.swift - Back button label */ +"tools.lineOfSight.back" = "Voltar"; + +/* Location: LineOfSightView.swift - Point A annotation */ +"tools.lineOfSight.pointA" = "Ponto A"; + +/* Location: LineOfSightView.swift - Point B annotation */ +"tools.lineOfSight.pointB" = "Ponto B"; + +/* Location: LineOfSightView.swift - Repeater annotation */ +"tools.lineOfSight.repeater" = "Repetidor"; + +/* Location: LineOfSightView.swift - Points section title */ +"tools.lineOfSight.points" = "Pontos"; + +/* Location: LineOfSightView.swift - Cancel button */ +"tools.lineOfSight.cancel" = "Cancelar"; + +/* Location: LineOfSightView.swift - Relocating message, %@ is point name */ +"tools.lineOfSight.relocating" = "A reposicionar %@..."; + +/* Location: LineOfSightView.swift - Tap map instruction */ +"tools.lineOfSight.tapMapInstruction" = "Toque no mapa para definir um novo local"; + +/* Location: LineOfSightView.swift - Not selected placeholder */ +"tools.lineOfSight.notSelected" = "Não selecionado"; + +/* Location: LineOfSightView.swift - Loading elevation status */ +"tools.lineOfSight.loadingElevation" = "A carregar altitude..."; + +/* Location: LineOfSightView.swift - Long press points hint */ +"tools.lineOfSight.longPressPointsHint" = "Toque e mantenha premido no mapa para adicionar um local"; + +/* Location: LineOfSightView.swift - Elevation unavailable warning */ +"tools.lineOfSight.elevationUnavailable" = "Dados de altitude indisponíveis. A utilizar o nível do mar (0 m) como aproximação."; + +/* Location: LineOfSightView.swift - Open in Maps button */ +"tools.lineOfSight.openInMaps" = "Abrir em Mapas"; + +/* Location: LineOfSightView.swift - Copy coordinates button */ +"tools.lineOfSight.copyCoordinates" = "Copiar coordenadas"; + +/* Location: LineOfSightView.swift - Share button */ +"tools.lineOfSight.share" = "Partilhar..."; + +/* Location: LineOfSightView.swift - Share label */ +"tools.lineOfSight.shareLabel" = "Partilhar"; + +/* Location: LineOfSightView.swift - Relocate button */ +"tools.lineOfSight.relocate" = "Reposicionar"; + +/* Location: LineOfSightView.swift - Done button */ +"tools.lineOfSight.done" = "OK"; + +/* Location: LineOfSightView.swift - Edit button */ +"tools.lineOfSight.edit" = "Editar"; + +/* Location: LineOfSightView.swift - Clear button */ +"tools.lineOfSight.clear" = "Limpar"; + +/* Location: LineOfSightView.swift - Ground elevation label */ +"tools.lineOfSight.groundElevation" = "Altitude do terreno"; + +/* Location: LineOfSightView.swift - Additional height label */ +"tools.lineOfSight.additionalHeight" = "Altura adicional"; + +/* Location: LineOfSightView.swift - Total height label */ +"tools.lineOfSight.totalHeight" = "Altura total"; + +/* Location: LineOfSightView.swift - Add repeater button */ +"tools.lineOfSight.addRepeater" = "Adicionar repetidor"; + +/* Location: LineOfSightView.swift - Analyze button */ +"tools.lineOfSight.analyze" = "Analisar linha de vista"; + +/* Location: LineOfSightView.swift - Terrain profile section */ +"tools.lineOfSight.terrainProfile" = "Perfil do terreno"; + +/* Location: LineOfSightView.swift - Earth curvature note, %@ is k-factor */ +"tools.lineOfSight.earthCurvature" = "Corrigido para a curvatura da Terra (%@)"; + +/* Location: LineOfSightView.swift - Drag to adjust tooltip */ +"tools.lineOfSight.dragToAdjust" = "Arraste para ajustar"; + +/* Location: LineOfSightView.swift - RF Settings section */ +"tools.lineOfSight.rfSettings" = "Definições RF"; + +/* Location: LineOfSightView.swift - Frequency label */ +"tools.lineOfSight.frequency" = "Frequência"; + +/* Location: LineOfSightView.swift - MHz unit */ +"tools.lineOfSight.mhz" = "MHz"; + +/* Location: LineOfSightView.swift - Refraction label */ +"tools.lineOfSight.refraction" = "Refração"; + +/* Location: LineOfSightView.swift - Refraction: none */ +"tools.lineOfSight.refraction.none" = "Nenhuma"; + +/* Location: LineOfSightView.swift - Refraction: standard */ +"tools.lineOfSight.refraction.standard" = "Padrão (k=1,33)"; + +/* Location: LineOfSightView.swift - Refraction: ducting */ +"tools.lineOfSight.refraction.ducting" = "Ducting (k=4)"; + +/* Location: LineOfSightView.swift - Analyzing progress */ +"tools.lineOfSight.analyzing" = "A analisar o caminho..."; + +/* Location: LineOfSightView.swift - Analysis failed title */ +"tools.lineOfSight.analysisFailed" = "A análise falhou"; + +/* Location: LineOfSightView.swift - Retry button */ +"tools.lineOfSight.retry" = "Tentar novamente"; + +/* Location: LineOfSightView.swift - Repeater location map item name */ +"tools.lineOfSight.repeaterLocation" = "Localização do repetidor"; + +/* Location: LineOfSightViewModel.swift - Dropped pin display name */ +"tools.lineOfSight.droppedPin" = "Alfinete largado"; + +/* Location: LOSRepeaterPinView.swift - Accessibility hint for repeater pins */ +/* Location: LOSPointPinView.swift - Accessibility hint for point pins */ +/* Location: LOSRepeaterTargetPinView.swift - Accessibility hint for repeater target */ +// MARK: - Terrain Profile Canvas + +/* Location: TerrainProfileCanvas.swift - Empty state title */ +"tools.lineOfSight.noData" = "Sem dados"; + +/* Location: TerrainProfileCanvas.swift - Empty state description */ +"tools.lineOfSight.selectTwoPoints" = "Selecione dois pontos para analisar"; + +/* Location: TerrainProfileCanvas.swift - Legend: terrain */ +"tools.lineOfSight.legend.terrain" = "Terreno"; + +/* Location: TerrainProfileCanvas.swift - Legend: line of sight */ +"tools.lineOfSight.legend.los" = "LOS"; + +/* Location: TerrainProfileCanvas.swift - Legend: clear */ +"tools.lineOfSight.legend.clear" = "Livre"; + +/* Location: TerrainProfileCanvas.swift - Legend: obstructed */ +"tools.lineOfSight.legend.obstructed" = "Obstruído"; + +/* Location: TerrainProfileCanvas.swift - Indirect route label */ +"tools.lineOfSight.indirectRoute" = "Rota indireta via R · Reposicione no mapa para ajustar"; + +/* Location: TerrainProfileCanvas.swift - Elevation data attribution */ +"tools.lineOfSight.elevationAttribution" = "Dados de altitude: Copernicus DEM GLO-90 via Open-Meteo"; + +// MARK: - Results Card View + +/* Location: ResultsCardView.swift - Section title */ +"tools.lineOfSight.results" = "Resultados"; + +/* Location: ResultsCardView.swift - Loss suffix */ +"tools.lineOfSight.loss" = "perda"; + +/* Location: ResultsCardView.swift - Path loss breakdown section */ +"tools.lineOfSight.pathLossBreakdown" = "Detalhe da perda de caminho"; + +/* Location: ResultsCardView.swift - Free space loss label */ +"tools.lineOfSight.freeSpaceLoss" = "Perda em espaço livre"; + +/* Location: ResultsCardView.swift - Diffraction loss label */ +"tools.lineOfSight.diffractionLoss" = "Perda por difração"; + +/* Location: ResultsCardView.swift - Total label */ +"tools.lineOfSight.total" = "Total"; + +/* Location: ResultsCardView.swift - Clearance section title */ +"tools.lineOfSight.clearance" = "Folga"; + +/* Location: ResultsCardView.swift - Obstructions found label */ +"tools.lineOfSight.obstructionsFound" = "Obstruções encontradas"; + +/* Location: ResultsCardView.swift - Status label */ +"tools.lineOfSight.status" = "Estado"; + +/* Location: ResultsCardView.swift - Distance label */ +"tools.lineOfSight.distance" = "Distância"; + +/* Location: ResultsCardView.swift - Worst clearance short label */ +"tools.lineOfSight.worstClearanceShort" = "Pior folga"; + +/* Location: ResultsCardView.swift - Assumptions footnote; %1$@ is frequency, %2$@ is k-factor */ +"tools.lineOfSight.assumptions" = "%1$@, %2$@, limiar de 60%% da 1.ª zona de Fresnel"; + +/* Location: ClearanceStatusView.swift - Clearance percentage, %lld is percent */ +"tools.lineOfSight.clearancePercent" = "%lld%% de folga"; + +/* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: clear */ +"tools.lineOfSight.status.clear" = "Livre"; + +/* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: marginal */ +"tools.lineOfSight.status.marginal" = "Marginal"; + +/* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: partial obstruction */ +"tools.lineOfSight.status.partialObstruction" = "Obstrução parcial"; + +/* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: blocked */ +"tools.lineOfSight.status.blocked" = "Bloqueado"; + +/* Location: ResultsCardView.swift - Subtitle shown when path is blocked */ +"tools.lineOfSight.status.blockedSubtitle" = "O caminho direto intersecta o terreno"; + +// MARK: - CLI Tool View + +/* Location: CLIToolView.swift - Tool selection label */ +"tools.cli" = "CLI"; + +/* Location: CLIToolView.swift - Disconnected state title */ +"tools.cli.notConnected" = "Não ligado"; + +/* Location: CLIToolView.swift - Disconnected state description */ +"tools.cli.notConnectedDescription" = "Ligue o rádio mesh para utilizar a CLI."; + +/* Location: CLIToolView.swift - Prompt suffix */ +"tools.cli.promptSuffix" = ">"; + +/* Location: CLIToolView.swift - Waiting indicator */ +/* Location: CLIToolView.swift - Disconnected prompt */ +"tools.cli.disconnected" = "desligado"; + +/* Location: CLIToolView.swift - Help command output header */ +"tools.cli.helpHeader" = "Comandos disponíveis:"; + +/* Location: CLIToolView.swift - Session list header */ +"tools.cli.sessionListHeader" = "Sessões ativas:"; + +/* Location: CLIToolView.swift - Local session label */ +"tools.cli.sessionLocal" = "local"; + +/* Location: CLIToolView.swift - No sessions message */ +/* Location: CLIToolView.swift - History empty message */ +/* Location: CLIToolView.swift - Unknown command error */ +"tools.cli.unknownCommand" = "Comando desconhecido:"; + +/* Location: CLIToolView.swift - Login success */ +"tools.cli.loginSuccess" = "Sessão iniciada em"; + +/* Location: CLIToolView.swift - Login failed */ +"tools.cli.loginFailed" = "Falha ao iniciar sessão:"; + +/* Location: CLIToolView.swift - Login failed reason */ +"tools.cli.loginFailedAuth" = "Falha na autenticação"; + +/* Location: CLIToolView.swift - Node not found error */ +"tools.cli.nodeNotFound" = "Nodo não encontrado:"; + +/* Location: CLIToolView.swift - Password required error */ +"tools.cli.passwordRequired" = "Palavra-passe necessária"; + +/* Location: CLIToolViewModel.swift - Password prompt */ +"tools.cli.passwordPrompt" = "Palavra-passe:"; + +/* Location: CLIToolViewModel.swift - Login countdown */ +"tools.cli.loggingIn" = "A iniciar sessão... (%ds)"; + +/* Location: CLIToolView.swift - Logout success */ +"tools.cli.logoutSuccess" = "Sessão terminada"; + +/* Location: CLIToolView.swift - Not logged in error */ +"tools.cli.notLoggedIn" = "Sem sessão iniciada em nenhum repetidor"; + +/* Location: CLIToolView.swift - Session switched */ +"tools.cli.sessionSwitched" = "Sessão alterada para"; + +/* Location: CLIToolView.swift - Session not found */ +"tools.cli.sessionNotFound" = "Sessão não encontrada:"; + +/* Location: CLIToolView.swift - Command timeout */ +"tools.cli.timeout" = "Tempo esgotado à espera da resposta"; + +/* Location: CLIToolView.swift - Command cancelled */ +"tools.cli.cancelled" = "Comando cancelado"; + +/* Location: CLIToolView.swift - Login from local only */ +"tools.cli.loginFromLocalOnly" = "O início de sessão só está disponível a partir da sessão local"; + +/* Location: CLIToolView.swift - Login usage */ +"tools.cli.loginUsage" = "Uso: login [-f] "; + +/* Location: CLIToolView.swift - Accessory button: history up */ +"tools.cli.historyUp" = "Comando anterior"; + +/* Location: CLIToolView.swift - Accessory button: history down */ +"tools.cli.historyDown" = "Comando seguinte"; + +/* Location: CLIToolView.swift - Accessory button: tab complete */ +"tools.cli.tabComplete" = "Completar com Tab"; + +/* Location: CLIInputAccessoryView.swift - Cursor left button */ +"tools.cli.cursorLeft" = "Mover o cursor para a esquerda"; + +/* Location: CLIInputAccessoryView.swift - Cursor right button */ +"tools.cli.cursorRight" = "Mover o cursor para a direita"; + +/* Location: CLIInputAccessoryView.swift - Paste button */ +"tools.cli.paste" = "Colar"; + +/* Location: CLIToolView.swift - Accessory button: clear */ +/* Location: CLIToolView.swift - Accessory button: sessions */ +"tools.cli.sessions" = "Sessões"; + +/* Location: CLIToolView.swift - Accessory button: dismiss */ +"tools.cli.dismiss" = "Ocultar teclado"; + +/* Location: CLIToolView.swift - Default device name */ +"tools.cli.defaultDevice" = "Dispositivo"; + +/* Location: CLIToolViewModel.swift - Help: login command */ +"tools.cli.helpLogin" = " login [-f] \n Iniciar sessão no repetidor (-f: esquecer palavra-passe guardada)"; + +/* Location: CLIToolViewModel.swift - Help: logout command */ +"tools.cli.helpLogout" = " logout\n Terminar sessão remota"; + +/* Location: CLIToolViewModel.swift - Help: session list command */ +"tools.cli.helpSessionList" = " session list\n Mostrar sessões ativas"; + +/* Location: CLIToolViewModel.swift - Help: session local command */ +"tools.cli.helpSessionLocal" = " session local\n Mudar para a sessão local"; + +/* Location: CLIToolViewModel.swift - Help: session name command */ +"tools.cli.helpSessionName" = " session \n Mudar para a sessão"; + +/* Location: CLIToolViewModel.swift - Help: session shortcut */ +"tools.cli.helpSessionShortcut" = " s\n Mudar para a sessão n (ex.: s1, s2)"; + +/* Location: CLIToolViewModel.swift - Help: clear command */ +"tools.cli.helpClear" = " clear\n Limpar o terminal"; + +/* Location: CLIToolViewModel.swift - Help: help command */ +"tools.cli.helpHelp" = " help\n Mostrar esta ajuda"; + +/* Location: CLIToolViewModel.swift - Help: nodes command */ +"tools.cli.helpNodes" = " nodes\n Mostrar lista de repetidores e salas"; + +/* Location: CLIToolViewModel.swift - Help: channels command */ +"tools.cli.helpChannels" = " channels\n Mostrar lista de canais com números de slot do dispositivo"; + +/* Location: CLIToolViewModel.swift - Help: repeater commands header */ +"tools.cli.helpRepeaterHeader" = "Comandos do repetidor (passthrough):"; + +/* Location: CLIToolViewModel.swift - Help: repeater commands list 1 */ +"tools.cli.helpRepeaterList1" = " ver, clock, reboot, advert, neighbors"; + +/* Location: CLIToolViewModel.swift - Help: repeater commands list 2 */ +"tools.cli.helpRepeaterList2" = " get/set , password "; + +/* Location: CLIToolViewModel.swift - Help: repeater commands list 3 */ +"tools.cli.helpRepeaterList3" = " log start/stop/erase"; + +/* Location: CLIToolViewModel.swift - Help: repeater commands list 4 */ +"tools.cli.helpRepeaterList4" = " setperm, tempradio, neighbor.remove"; + +/* Location: CLIToolViewModel+Sessions.swift - Nodes header with count */ +"tools.cli.nodesHeader" = "Nodos (%lld):"; + +/* Location: CLIToolViewModel+Sessions.swift - Channels header with count */ +"tools.cli.channelsHeader" = "Canais (%lld):"; + +/* Location: CLIToolViewModel+Sessions.swift - No nodes found */ +"tools.cli.noNodes" = "Nenhum nodo encontrado"; + +/* Location: CLIToolViewModel+Sessions.swift - No channels found */ +"tools.cli.noChannels" = "Nenhum canal encontrado"; + +/* Location: CLIToolViewModel+Sessions.swift - Channel empty name placeholder */ +"tools.cli.channelEmpty" = "vazio"; + +/* Location: CLIToolViewModel.swift - Welcome banner line 1 */ +"tools.cli.welcomeLine1" = "MeshCore One CLI"; + +/* Location: CLIToolViewModel.swift - Welcome banner line 2 */ +"tools.cli.welcomeConnected" = "Ligado a %@"; + +/* Location: CLIToolViewModel.swift - Welcome banner line 3 */ +"tools.cli.welcomeHint" = "Introduza 'help' para ver os comandos disponíveis."; + +/* Location: CLIToolView.swift - Jump to bottom button */ +"tools.cli.jumpToBottom" = "Ir para o fundo"; + +/* Location: CLIToolView.swift - Accessibility label for command input */ +"tools.cli.commandInput" = "Introdução de comando"; + +/* Location: CLIToolView.swift - Accessibility label for command prompt */ +"tools.cli.commandPrompt" = "Prompt de comando"; + +/* Location: CLIInputAccessoryView.swift - Cancel operation button label */ +"tools.cli.cancelOperation" = "Cancelar operação"; + +/* Location: CLIToolViewModel.swift - Command timeout (post-login) */ +"tools.cli.commandTimeout" = "O pedido expirou"; + +/* Location: CLIToolViewModel.swift - Reboot command confirmation */ +"tools.cli.rebootSent" = "Comando de reinício enviado"; + +/* Accessibility label for completion suggestions container */ +/* Accessibility value for completion suggestions - %lld is count, %@ is selected */ +/* Location: CLIToolViewModel.swift - Confirmation prompt for a dangerous local command; %@ is the command name */ +"tools.cli.confirmPrompt" = "confirmar %@? (yes/no):"; + +/* Location: CLIToolViewModel+LocalCommands.swift - Usage for the get command */ +"tools.cli.usageGet" = "Uso: get "; + +/* Location: CLIToolViewModel+LocalCommands.swift - Usage for the set command */ +"tools.cli.usageSet" = "Uso: set "; + +/* Location: CLIToolViewModel+LocalCommands.swift - Usage for set radio */ +"tools.cli.usageSetRadio" = "Uso: set radio ,,,"; + +/* Location: CLIToolViewModel+LocalCommands.swift - Invalid value error */ +"tools.cli.invalidValue" = "Valor inválido"; + +/* Location: CLIToolViewModel+LocalCommands.swift - Custom var key/value contains a reserved character */ +"tools.cli.invalidCustomVarToken" = "As chaves não podem conter ':' nem ','; os valores não podem conter ','"; + +/* Location: CLIToolViewModel.swift - Local radio commands help header */ +"tools.cli.helpLocalHeader" = "Comandos de rádio locais:"; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 1 */ +"tools.cli.helpLocalList1" = " clock [sync], ver, board"; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 2 */ +"tools.cli.helpLocalList2" = " advert (zero-hop), floodadv (flood), reboot"; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 3 */ +"tools.cli.helpLocalList3" = " get , set "; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 4 */ +"tools.cli.helpLocalList4" = " keys: name, lat, lon, tx, radio, freq, multi.acks, path.hash.mode, public.key, bat"; + +/* Location: CLIToolViewModel.swift - Local radio commands help line 5 */ +"tools.cli.helpLocalList5" = " get custom (todas as variáveis personalizadas do firmware); chaves get/set desconhecidas são encaminhadas para as variáveis personalizadas"; + +// MARK: - Saved Paths + +/* Location: SavedPathsViewModel.swift - Error loading saved paths */ +"tools.savedPaths.loadFailed" = "Não foi possível carregar os caminhos guardados."; + +/* Location: SavedPathsViewModel.swift - Error renaming a saved path */ +"tools.savedPaths.renameFailed" = "Não foi possível mudar o nome do caminho."; + +/* Location: SavedPathsViewModel.swift - Error deleting a saved path */ +"tools.savedPaths.deleteFailed" = "Não foi possível eliminar o caminho."; + +// MARK: - App Intents + +/* Location: MessageTargetEntity.swift - App Intents entity type name for a send target (a contact DM or a channel broadcast) */ +"intent.entity.target" = "Destinatário"; + +/* Location: MessageTargetEntity.swift - App Intents recipient-picker subtitle marking a channel (broadcast) target */ +"intent.entity.channel" = "Canal"; + +/* Location: StatusQueryIntent.swift - App Intents: title of the radio status query intent */ +"intent.status.title" = "Verificar estado do rádio"; + +/* Location: MC1AppShortcutsProvider.swift - App Shortcuts: short title for the radio status shortcut */ +"intent.status.shortTitle" = "Estado do rádio"; + +/* Location: StatusQueryIntent.swift - App Intents: description of the radio status query intent */ +"intent.status.description" = "Indica o nome, o estado da ligação e o último nível de bateria conhecido do rádio ligado."; + +/* Location: StatusQueryIntent.swift - Spoken when the app is still launching and has no state to read yet */ +"intent.status.dialog.notReady" = "O MeshCore One ainda está a iniciar. Tente novamente daqui a pouco."; + +/* Location: StatusQueryIntent.swift - Spoken when connected; %1$@ is the radio name, %2$lld is the cached battery percent */ +"intent.status.dialog.connectedWithBattery" = "%1$@ está ligado. A bateria está a cerca de %2$lld%%, segundo a última leitura."; + +/* Location: StatusQueryIntent.swift - Spoken when connected but no battery reading is available; %@ is the radio name */ +"intent.status.dialog.connectedNoBattery" = "%@ está ligado. Nenhuma leitura de bateria disponível."; + +/* Location: StatusQueryIntent.swift - Spoken while the radio is connecting; %@ is the radio name */ +"intent.status.dialog.connecting" = "%@ está a estabelecer ligação."; + +/* Location: StatusQueryIntent.swift - Spoken when not connected and a last radio name is known; %@ is the radio name */ +"intent.status.dialog.disconnectedNamed" = "%@ não está ligado."; + +/* Location: StatusQueryIntent.swift - Spoken when not connected and no radio has been connected before */ +"intent.status.dialog.disconnectedUnknown" = "Nenhum rádio está ligado."; + +/* Location: StatusQueryIntent.swift - Generic radio name used when no device name is known */ +"intent.status.radioFallbackName" = "O seu rádio"; + +/* Location: SendMessageIntent.swift - App Intents: title of the send message intent */ +"intent.send.title" = "Enviar uma mensagem"; + +/* Location: MC1AppShortcutsProvider.swift - App Shortcuts: short title for the send message shortcut */ +"intent.send.shortTitle" = "Enviar mensagem"; + +/* Location: SendMessageIntent.swift - App Intents: description of the send message intent */ +"intent.send.description" = "Envia texto para um contacto ou um canal no rádio."; + +/* Location: SendMessageIntent.swift - App Intents: title of the recipient parameter (a contact or a channel) */ +"intent.send.param.target" = "Destinatário"; + +/* Location: SendMessageIntent.swift - App Intents: title of the message text parameter */ +"intent.send.param.message" = "Mensagem"; + +/* Location: SendMessageIntent.swift - Confirmation prompt shown before sending; %@ is the recipient name */ +"intent.send.confirm" = "Enviar esta mensagem para %@?"; + +/* Location: SendMessageIntent.swift - Spoken when the app must come to the foreground to send (still connecting or reconnecting) */ +"intent.send.foreground" = "Abra o MeshCore One para concluir o envio desta mensagem."; + +/* Location: SendMessageIntent.swift - App Intents parameter summary shown in the shortcut editor; ${message} and ${target} are framework parameter tokens */ +"Send ${message} to ${target}" = "Enviar ${message} para ${target}"; + +// MARK: - Send Advert Intent + +/* Location: SendAdvertIntent.swift - App Intents: title of the send advert intent */ +"intent.advert.title" = "Enviar um Advert"; + +/* Location: MC1AppShortcutsProvider.swift - App Shortcuts: short title for the send advert shortcut */ +"intent.advert.shortTitle" = "Enviar Advert"; + +/* Location: SendAdvertIntent.swift - App Intents: description of the send advert intent */ +"intent.advert.description" = "Emite um Advert deste rádio para que os nodos da mesh próximos o possam descobrir."; + +/* Location: SendAdvertIntent.swift - App Intents: title of the advert reach parameter (zero-hop or flood) */ +"intent.advert.param.reach" = "Âmbito"; + +/* Location: AdvertReach.swift - App Intents: type name for the advert reach enum */ +"intent.advert.reach.type" = "Âmbito do Advert"; + +/* Location: AdvertReach.swift - App Intents: zero-hop reach case (direct neighbors only) */ +"intent.advert.reach.zeroHop" = "Zero-hop"; + +/* Location: AdvertReach.swift - App Intents: flood reach case (entire mesh) */ +"intent.advert.reach.flood" = "Flood"; + +/* Location: SendAdvertIntent.swift - Spoken after a zero-hop advert is sent */ +"intent.advert.dialog.sentZeroHop" = "Advert enviado para os nodos próximos."; + +/* Location: SendAdvertIntent.swift - Spoken after a flood advert is sent */ +"intent.advert.dialog.sentFlood" = "Advert enviado por toda a mesh."; + +/* Location: SendAdvertIntent.swift - App Intents parameter summary shown in the shortcut editor; ${reach} is the framework parameter token */ +"Send a ${reach} advert" = "Enviar um Advert ${reach}"; diff --git a/MC1/Resources/Localization/pt.lproj/WhatsNew.strings b/MC1/Resources/Localization/pt.lproj/WhatsNew.strings new file mode 100644 index 000000000..451de4840 --- /dev/null +++ b/MC1/Resources/Localization/pt.lproj/WhatsNew.strings @@ -0,0 +1,31 @@ +/* + WhatsNew.strings + MC1 + + European Portuguese (pt-PT) translation of strings for the "What's New" sheet shown once after a major update. + AI-translated - please verify with native speakers. +*/ + +/* Title of the What's New sheet shown once after an app update */ +"whatsNew.title" = "Novidades"; + +/* Button to dismiss the What's New sheet */ +"whatsNew.continueButton" = "Continuar"; + +/* Link on the What's New sheet to the full GitHub release notes */ +"whatsNew.fullReleaseNotes" = "Notas de versão completas"; + +/* What's New v1.3 - Improved chats feature, title */ +"whatsNew.fasterChats.title" = "Chats melhorados"; +/* What's New v1.3 - Improved chats feature, description */ +"whatsNew.fasterChats.description" = "Ao abrir um chat, o ecrã salta para as mensagens novas em vez do fim. A hora aparece em cada balão, e o histórico e as pré-visualizações de ligações carregam mais depressa."; + +/* What's New v1.3 - Contact photos feature, title */ +"whatsNew.contactPhotos.title" = "Fotos de contactos"; +/* What's New v1.3 - Contact photos feature, description */ +"whatsNew.contactPhotos.description" = "Defina um avatar em qualquer contacto para reconhecer pessoas mais facilmente em listas e conversas."; + +/* What's New v1.3 - Map filters feature, title */ +"whatsNew.mapFilters.title" = "Filtros do mapa"; +/* What's New v1.3 - Map filters feature, description */ +"whatsNew.mapFilters.description" = "Filtre os alfinetes por favoritos, nodos descobertos e tipo de nodo."; diff --git a/MC1Tests/Intents/IntentErrorLocalizationTests.swift b/MC1Tests/Intents/IntentErrorLocalizationTests.swift index 8f26fabe6..78213ca82 100644 --- a/MC1Tests/Intents/IntentErrorLocalizationTests.swift +++ b/MC1Tests/Intents/IntentErrorLocalizationTests.swift @@ -6,7 +6,7 @@ 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 10 locales. +/// the raw key resolves to real copy in all 11 locales. struct IntentErrorLocalizationTests { /// The localizable table backing the `error.intent.*` keys. private static let table = "Localizable" @@ -23,9 +23,9 @@ struct IntentErrorLocalizationTests { (.advertFailed, "error.advertisement.sendFailed", L10n.Localizable.Error.Advertisement.sendFailed), ] - /// The 10 shipped locales. A key missing from any one would fall back to the + /// The 11 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", "it", "nl", "pl", "ru", "uk", "zh-Hans"] + private static let locales = ["de", "en", "es", "fr", "it", "nl", "pl", "pt", "ru", "uk", "zh-Hans"] // MARK: - Generated accessor agreement @@ -56,7 +56,7 @@ struct IntentErrorLocalizationTests { #expect(resolved?.isEmpty == false) } - // MARK: - No raw-key fallback across all 10 locales + // MARK: - No raw-key fallback across all 11 locales @Test func `every intent key resolves in every locale`() throws { for locale in Self.locales { diff --git a/MC1Tests/Intents/IntentMetadataLocalizationTests.swift b/MC1Tests/Intents/IntentMetadataLocalizationTests.swift index cf70f79ed..5659f8b1c 100644 --- a/MC1Tests/Intents/IntentMetadataLocalizationTests.swift +++ b/MC1Tests/Intents/IntentMetadataLocalizationTests.swift @@ -5,9 +5,9 @@ import Testing /// App Intents resolves every static metadata literal (`LocalizedStringResource` /// title, description, parameter title, short title, and `ParameterSummary`) /// against the `Tools` table in Siri's locale, so a key missing from any one of -/// the 10 shipped `.lproj` bundles surfaces as the raw key in that language. These +/// the 11 shipped `.lproj` bundles surfaces as the raw key in that language. These /// tests pin every metadata key the intents reference and confirm each resolves -/// to real copy in all 10 locales, never the raw-key fallback. +/// to real copy in all 11 locales, never the raw-key fallback. struct IntentMetadataLocalizationTests { /// The table backing every App Intents static metadata literal. private static let table = "Tools" @@ -36,9 +36,9 @@ struct IntentMetadataLocalizationTests { "intent.advert.reach.flood", ] - /// The 10 shipped locales. A key missing from any one falls back to the raw + /// The 11 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", "it", "nl", "pl", "ru", "uk", "zh-Hans"] + private static let locales = ["de", "en", "es", "fr", "it", "nl", "pl", "pt", "ru", "uk", "zh-Hans"] @Test func `every metadata key resolves in every locale`() throws { for locale in Self.locales { diff --git a/MC1Widgets/Resources/pt.lproj/Localizable.strings b/MC1Widgets/Resources/pt.lproj/Localizable.strings new file mode 100644 index 000000000..0f0edfbf3 --- /dev/null +++ b/MC1Widgets/Resources/pt.lproj/Localizable.strings @@ -0,0 +1,16 @@ +/* Widget localization — European Portuguese (pt-PT) + AI-translated - please verify with native speakers. +*/ + +/* Connection status shown when radio is disconnected */ +"Disconnected" = "Desligado"; + + +/* Accessibility: packet rate */ +"%lld packets per minute" = "%lld pacotes por minuto"; + +/* Accessibility: battery level */ +"Battery %lld percent" = "Bateria a %lld por cento"; + +/* Control Center button: opens the app */ +"Open MeshCore One" = "Abrir MeshCore One"; diff --git a/MC1Widgets/Resources/pt.lproj/Localizable.stringsdict b/MC1Widgets/Resources/pt.lproj/Localizable.stringsdict new file mode 100644 index 000000000..f3f41ad54 --- /dev/null +++ b/MC1Widgets/Resources/pt.lproj/Localizable.stringsdict @@ -0,0 +1,43 @@ + + + + + + %lld unread + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + one + %lld por ler + other + %lld por ler + + + %lld unread messages + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + one + %lld mensagem não lida + other + %lld mensagens não lidas + + + + diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index b41129c94..97d38384e 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -12,6 +12,7 @@ MeshCore One supports multiple languages. You can help improve translations enti | German | de | AI-translated | | Italian | it | Verified by corradoignoti #376 | | Polish | pl | AI-translated | +| Portuguese (Portugal) | pt | AI-translated | | Russian | ru | AI-translated | | Simplified Chinese | zh-Hans | Verified by MGJ520 #225 | | Spanish | es | AI-translated | @@ -152,7 +153,7 @@ Add the same key to all other language files. You can use AI translation as a st Use `.stringsdict` files for strings that change based on quantity. -#### Simple Languages (English, German, Dutch, Spanish, French) +#### Simple Languages (English, German, Dutch, Spanish, French, Italian, Portuguese) These languages use two forms: `one` (exactly 1) and `other` (0, 2+). From c3726ce5673e215fd95dedb9ebf9e51b0439a2af Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:04:55 -0700 Subject: [PATCH 26/47] fix(backup): fetch leftover flood sentinels - Store DiscoveredNode.outPathLength as Int so leftover SQLite -1 no longer traps fetch - DTO still uses UInt8 via truncatingIfNeeded; envelope unchanged --- .../MC1Services/Models/DiscoveredNode.swift | 10 ++- .../Services/PersistenceStore.swift | 9 +- .../DiscoveredNodeUnsignedFetchTests.swift | 88 +++++++++++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift diff --git a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift index def496802..693361288 100644 --- a/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift +++ b/MC1Services/Sources/MC1Services/Models/DiscoveredNode.swift @@ -39,8 +39,10 @@ final class DiscoveredNode { /// Node longitude var longitude: Double - /// Encoded routing path length (0xFF = flood) - var outPathLength: UInt8 + /// Encoded routing path length (0xFF = flood). Stored as Int so leftover Int8 + /// flood sentinels (-1) survive SwiftData fetch; the DTO exposes UInt8 via + /// truncatingIfNeeded. + var outPathLength: Int /// Routing path data (up to 64 bytes) var outPath: Data @@ -79,7 +81,7 @@ final class DiscoveredNode { self.lastAdvertTimestamp = lastAdvertTimestamp self.latitude = latitude self.longitude = longitude - self.outPathLength = outPathLength + self.outPathLength = Int(outPathLength) self.outPath = outPath self.inboundHopCount = inboundHopCount self.inboundHopAdvertTimestamp = inboundHopAdvertTimestamp @@ -188,7 +190,7 @@ public struct DiscoveredNodeDTO: Sendable, Equatable, Identifiable, Codable, Rep lastAdvertTimestamp = node.lastAdvertTimestamp latitude = node.latitude longitude = node.longitude - outPathLength = node.outPathLength + outPathLength = UInt8(truncatingIfNeeded: node.outPathLength) outPath = node.outPath inboundHopCount = node.inboundHopCount inboundHopAdvertTimestamp = node.inboundHopAdvertTimestamp diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift index b2373f69a..dd7f8b3c3 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift @@ -82,8 +82,9 @@ public actor PersistenceStore: PersistenceStoreProtocol { /// 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). + /// - v1→v2: Contact.outPathLength, DiscoveredNode.outPathLength changed Int8→UInt8. + /// SQLite INTEGER is identical; leftover Int8 flood sentinels (-1) + /// still trap UInt8 fetch (SwiftData value-preserves, not bit-cast). /// Added MessageRepeat.pathLength (UInt8, default 0). /// Added SavedTracePath.hashSize (Int, default 1). /// - v2→v3: Added PendingSend (new table; no migration impact on existing rows). @@ -96,6 +97,8 @@ public actor PersistenceStore: PersistenceStoreProtocol { /// index. /// - v5→v6: Added Contact.avatarImageData (Data?, default nil) storing a /// user-picked profile picture as a compressed JPEG blob. + /// - v6→v7: DiscoveredNode.outPathLength changed UInt8→Int so leftover -1 + /// rows fetch. DTO still exposes UInt8 via truncatingIfNeeded. public static func createContainer(inMemory: Bool = false) throws -> ModelContainer { if !inMemory { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! @@ -180,7 +183,7 @@ public actor PersistenceStore: PersistenceStoreProtocol { existingNode.lastAdvertTimestamp = frame.lastAdvertTimestamp existingNode.latitude = frame.latitude existingNode.longitude = frame.longitude - existingNode.outPathLength = frame.outPathLength + existingNode.outPathLength = Int(frame.outPathLength) existingNode.outPath = frame.outPath node = existingNode isNew = false diff --git a/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift b/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift new file mode 100644 index 000000000..2a706b568 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift @@ -0,0 +1,88 @@ +import Foundation +@testable import MC1Services +import MeshCore +import SQLite3 +import SwiftData +import Testing + +private let discoveredNodeTable = "ZDISCOVEREDNODE" +private let outPathLengthColumn = "ZOUTPATHLENGTH" +private let sqliteBusyTimeoutMilliseconds: Int32 = 5000 + +@Suite("DiscoveredNode unsigned fetch") +struct DiscoveredNodeUnsignedFetchTests { + @Test + func `Backup export of a leftover Int8 flood sentinel (-1)`() async throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("discovered-int8-\(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() + do { + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let container = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let store = PersistenceStore(modelContainer: container) + let frame = ContactFrame( + publicKey: Data(repeating: 0x44, count: 32), + type: .repeater, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: "LegacyFlood", + lastAdvertTimestamp: 1, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + _ = try await store.upsertDiscoveredNode(radioID: radioID, from: frame) + } + + _ = try sqlite(storeURL, "UPDATE \(discoveredNodeTable) SET \(outPathLengthColumn) = -1;") + #expect(try sqlite(storeURL, "SELECT \(outPathLengthColumn) FROM \(discoveredNodeTable);") == "-1") + + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let reopened = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let snapshot = try await PersistenceStore(modelContainer: reopened).fetchBackupExportSnapshot() + let node = try #require(snapshot.discoveredNodes.first) + #expect(snapshot.discoveredNodes.count == 1) + #expect(node.outPathLength == PacketBuilder.floodPathSentinel) + } +} + +private func sqlite(_ storeURL: URL, _ sql: String) throws -> String { + var db: OpaquePointer? + let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX + guard sqlite3_open_v2(storeURL.path, &db, flags, nil) == SQLITE_OK, let db else { + throw SQLiteProbeError(message: "open failed: \(storeURL.path)") + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, sqliteBusyTimeoutMilliseconds) + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw SQLiteProbeError(message: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(statement) } + + var rows: [String] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { break } + guard status == SQLITE_ROW else { + throw SQLiteProbeError(message: String(cString: sqlite3_errmsg(db))) + } + if let value = sqlite3_column_text(statement, 0) { + rows.append(String(cString: value)) + } + } + return rows.joined(separator: "\n") +} + +private struct SQLiteProbeError: Error { + let message: String +} From fee5d71e13a0c398ad02905af139c326d5f1acba Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:27:19 -0700 Subject: [PATCH 27/47] fix(contacts): fetch leftover flood sentinels - Store Contact.outPathLength as Int so leftover SQLite -1 no longer traps fetch - DTO still uses UInt8 via truncatingIfNeeded; envelope unchanged --- .../Sources/MC1Services/Models/Contact.swift | 18 ++++---- .../Services/PersistenceStore.swift | 2 + .../DiscoveredNodeUnsignedFetchTests.swift | 45 ++++++++++++++++++- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/MC1Services/Sources/MC1Services/Models/Contact.swift b/MC1Services/Sources/MC1Services/Models/Contact.swift index af50041a9..7b6ecd2e2 100644 --- a/MC1Services/Sources/MC1Services/Models/Contact.swift +++ b/MC1Services/Sources/MC1Services/Models/Contact.swift @@ -32,8 +32,10 @@ public final class Contact { /// 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 + /// Encoded outbound path length (0xFF = flood; upper 2 bits = hash mode, + /// lower 6 bits = hop count). Stored as Int so leftover Int8 flood sentinels + /// (-1) survive SwiftData fetch; the DTO exposes UInt8 via truncatingIfNeeded. + public var outPathLength: Int /// Outgoing routing path (up to 64 bytes) public var outPath: Data @@ -116,7 +118,7 @@ public final class Contact { self.name = name self.typeRawValue = typeRawValue self.flags = flags - self.outPathLength = outPathLength + self.outPathLength = Int(outPathLength) self.outPath = outPath self.lastAdvertTimestamp = lastAdvertTimestamp self.latitude = latitude @@ -174,7 +176,7 @@ public final class Contact { name = dto.name typeRawValue = dto.typeRawValue flags = dto.flags - outPathLength = dto.outPathLength + outPathLength = Int(dto.outPathLength) outPath = dto.outPath lastAdvertTimestamp = dto.lastAdvertTimestamp latitude = dto.latitude @@ -233,7 +235,7 @@ public extension Contact { /// Whether this contact uses flood routing var isFloodRouted: Bool { - outPathLength == PacketBuilder.floodPathSentinel + UInt8(truncatingIfNeeded: outPathLength) == PacketBuilder.floodPathSentinel } /// Whether this contact has a known, valid location @@ -261,7 +263,7 @@ public extension Contact { 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 + outPathLength = Int(frame.outPathLength) outPath = frame.outPath lastAdvertTimestamp = frame.lastAdvertTimestamp latitude = frame.latitude @@ -276,7 +278,7 @@ public extension Contact { type: type, typeRawValue: typeRawValue, flags: flags, - outPathLength: outPathLength, + outPathLength: UInt8(truncatingIfNeeded: outPathLength), outPath: outPath, name: name, lastAdvertTimestamp: lastAdvertTimestamp, @@ -323,7 +325,7 @@ public struct ContactDTO: Sendable, Equatable, Identifiable, Hashable, Codable, name = contact.name typeRawValue = contact.typeRawValue flags = contact.flags - outPathLength = contact.outPathLength + outPathLength = UInt8(truncatingIfNeeded: contact.outPathLength) outPath = contact.outPath lastAdvertTimestamp = contact.lastAdvertTimestamp latitude = contact.latitude diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift index dd7f8b3c3..07765a503 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore.swift @@ -99,6 +99,8 @@ public actor PersistenceStore: PersistenceStoreProtocol { /// user-picked profile picture as a compressed JPEG blob. /// - v6→v7: DiscoveredNode.outPathLength changed UInt8→Int so leftover -1 /// rows fetch. DTO still exposes UInt8 via truncatingIfNeeded. + /// - v7→v8: Contact.outPathLength changed UInt8→Int for the same leftover + /// Int8 flood sentinel. DTO still exposes UInt8 via truncatingIfNeeded. public static func createContainer(inMemory: Bool = false) throws -> ModelContainer { if !inMemory { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! diff --git a/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift b/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift index 2a706b568..03440ece3 100644 --- a/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/DiscoveredNodeUnsignedFetchTests.swift @@ -6,10 +6,11 @@ import SwiftData import Testing private let discoveredNodeTable = "ZDISCOVEREDNODE" +private let contactTable = "ZCONTACT" private let outPathLengthColumn = "ZOUTPATHLENGTH" private let sqliteBusyTimeoutMilliseconds: Int32 = 5000 -@Suite("DiscoveredNode unsigned fetch") +@Suite("Unsigned path length fetch") struct DiscoveredNodeUnsignedFetchTests { @Test func `Backup export of a leftover Int8 flood sentinel (-1)`() async throws { @@ -52,6 +53,48 @@ struct DiscoveredNodeUnsignedFetchTests { #expect(snapshot.discoveredNodes.count == 1) #expect(node.outPathLength == PacketBuilder.floodPathSentinel) } + + @Test + func `Backup export of a leftover Contact Int8 flood sentinel (-1)`() async throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("contact-int8-\(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() + do { + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let container = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let store = PersistenceStore(modelContainer: container) + let frame = ContactFrame( + publicKey: Data(repeating: 0x55, count: 32), + type: .chat, + flags: 0, + outPathLength: PacketBuilder.floodPathSentinel, + outPath: Data(), + name: "LegacyContact", + lastAdvertTimestamp: 1, + latitude: 0, + longitude: 0, + lastModified: 0 + ) + _ = try await store.saveContact(radioID: radioID, from: frame) + } + + _ = try sqlite(storeURL, "UPDATE \(contactTable) SET \(outPathLengthColumn) = -1;") + #expect(try sqlite(storeURL, "SELECT \(outPathLengthColumn) FROM \(contactTable);") == "-1") + + let cfg = ModelConfiguration(schema: PersistenceStore.schema, url: storeURL) + let reopened = try ModelContainer(for: PersistenceStore.schema, configurations: [cfg]) + let snapshot = try await PersistenceStore(modelContainer: reopened).fetchBackupExportSnapshot() + let contact = try #require(snapshot.contacts.first) + #expect(snapshot.contacts.count == 1) + #expect(contact.outPathLength == PacketBuilder.floodPathSentinel) + } } private func sqlite(_ storeURL: URL, _ sql: String) throws -> String { From 6f15948bdc17f858b763fddbdfc49285fb71d800 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:38:51 -0700 Subject: [PATCH 28/47] feat(chats): show fallback ? on repeat details - Resolve heard-repeat names with NeighborNameResolver so a short-prefix collision is .fallback - Show FallbackMatchIndicatorView on those Repeat Details rows --- .../Chats/Components/RepeatRowView.swift | 68 +++++++++++------- .../Chats/Components/RepeatRowViewTests.swift | 71 +++++++++++++++++++ 2 files changed, 113 insertions(+), 26 deletions(-) create mode 100644 MC1Tests/Views/Chats/Components/RepeatRowViewTests.swift diff --git a/MC1/Views/Chats/Components/RepeatRowView.swift b/MC1/Views/Chats/Components/RepeatRowView.swift index 38ee22deb..19ba4a348 100644 --- a/MC1/Views/Chats/Components/RepeatRowView.swift +++ b/MC1/Views/Chats/Components/RepeatRowView.swift @@ -1,4 +1,3 @@ -// MC1/Views/Chats/Components/RepeatRowView.swift import CoreLocation import MC1Services import SwiftUI @@ -11,16 +10,19 @@ struct RepeatRowView: View { let userLocation: CLLocation? var body: some View { + let resolution = repeaterResolution HStack(alignment: .top) { - // Left side: Repeater ID + name, hop count VStack(alignment: .leading, spacing: 2) { - HStack { + HStack(spacing: 6) { Text(repeatEntry.repeaterHashFormatted) .font(.body) .foregroundStyle(.secondary) .monospaced() - Text(repeaterName) + Text(resolution.displayName) .font(.body) + if resolution.matchKind == .fallback { + FallbackMatchIndicatorView() + } } Text(hopCountText) @@ -30,7 +32,6 @@ struct RepeatRowView: View { Spacer() - // Right side: Signal bars and metrics VStack(alignment: .trailing, spacing: 2) { Image(systemName: "cellularbars", variableValue: repeatEntry.snrLevel) .foregroundStyle(signalColor) @@ -45,13 +46,47 @@ struct RepeatRowView: View { } } .padding(.vertical, 4) - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Repeats.Row.accessibility(repeaterName)) + // .combine would swallow FallbackMatchIndicatorView; .contain keeps the popover a rotor stop. + .accessibilityElement(children: resolution.isFallback ? .contain : .combine) + .accessibilityLabel(L10n.Chats.Chats.Repeats.Row.accessibility(resolution.displayName)) .accessibilityValue(L10n.Chats.Chats.Repeats.Row.accessibilityValue(signalQuality, repeatEntry.snrFormatted, repeatEntry.rssiFormatted)) } + /// NeighborNameResolver, not RepeaterResolver.bestMatch: only the former reports `.fallback`. + static func resolution( + repeaterHash: Data?, + repeaters: [ContactDTO], + discoveredRepeaters: [DiscoveredNodeDTO], + userLocation: CLLocation? + ) -> NodeNameResolution { + guard let repeaterHash else { + return NodeNameResolution( + displayName: L10n.Chats.Chats.Repeats.unknownRepeater, + matchKind: .unresolved + ) + } + return NeighborNameResolver.resolve( + for: repeaterHash, + contacts: repeaters, + discoveredNodes: discoveredRepeaters, + userLocation: userLocation + ) ?? NodeNameResolution( + displayName: L10n.Chats.Chats.Repeats.unknownRepeater, + matchKind: .unresolved + ) + } + // MARK: - Helpers + private var repeaterResolution: NodeNameResolution { + Self.resolution( + repeaterHash: repeatEntry.repeaterHash, + repeaters: repeaters, + discoveredRepeaters: discoveredRepeaters, + userLocation: userLocation + ) + } + private var snrQuality: SNRQuality { repeatEntry.snrQuality } @@ -60,33 +95,14 @@ struct RepeatRowView: View { 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 - } - - 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 - } - - return L10n.Chats.Chats.Repeats.unknownRepeater - } } #Preview { diff --git a/MC1Tests/Views/Chats/Components/RepeatRowViewTests.swift b/MC1Tests/Views/Chats/Components/RepeatRowViewTests.swift new file mode 100644 index 000000000..73fa35ddc --- /dev/null +++ b/MC1Tests/Views/Chats/Components/RepeatRowViewTests.swift @@ -0,0 +1,71 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("RepeatRowView") +struct RepeatRowViewTests { + @Test + func `unique short prefix is exact`() { + let result = RepeatRowView.resolution( + repeaterHash: Data([0x3F]), + repeaters: [createRepeater(prefix: 0x3F, secondByte: 0x01, name: "Ridge")], + discoveredRepeaters: [], + userLocation: nil + ) + + #expect(result.displayName == "Ridge") + #expect(result.matchKind == .exact) + } + + @Test + func `colliding short prefix is fallback`() { + let result = RepeatRowView.resolution( + repeaterHash: Data([0x3F]), + repeaters: [ + createRepeater(prefix: 0x3F, secondByte: 0x01, name: "Ridge"), + createRepeater(prefix: 0x3F, secondByte: 0x02, name: "Valley") + ], + discoveredRepeaters: [], + userLocation: nil + ) + + #expect(result.matchKind == .fallback) + } + + @Test + func `unmatched hash is unresolved`() { + let result = RepeatRowView.resolution( + repeaterHash: Data([0xAA]), + repeaters: [createRepeater(prefix: 0x3F, secondByte: 0x01, name: "Ridge")], + discoveredRepeaters: [], + userLocation: nil + ) + + #expect(result.matchKind == .unresolved) + } + + private func createRepeater(prefix: UInt8, secondByte: UInt8, name: String) -> 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: 10, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: nil, + unreadCount: 0 + ) + } +} From 1e0ced64ad3d3c53ca927f8b1109bce266ca24a8 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:46:16 -0700 Subject: [PATCH 29/47] feat(wifi): accept hostnames when connecting The form only allowed dotted IPv4 even though the transport already resolves names. Keep the typed hostname so reconnect looks it up again. - Validate IPv4 and DNS hostnames, including .local - Switch the host field to a URL keyboard - Trim before save; update copy in all locales --- MC1/Resources/Generated/L10n.swift | 16 +++--- .../Localization/de.lproj/Localizable.strings | 6 +- .../Localization/de.lproj/Onboarding.strings | 10 ++-- .../Localization/en.lproj/Localizable.strings | 6 +- .../Localization/en.lproj/Onboarding.strings | 10 ++-- .../Localization/es.lproj/Localizable.strings | 6 +- .../Localization/es.lproj/Onboarding.strings | 10 ++-- .../Localization/fr.lproj/Localizable.strings | 6 +- .../Localization/fr.lproj/Onboarding.strings | 10 ++-- .../Localization/it.lproj/Localizable.strings | 6 +- .../Localization/it.lproj/Onboarding.strings | 10 ++-- .../Localization/nl.lproj/Localizable.strings | 6 +- .../Localization/nl.lproj/Onboarding.strings | 10 ++-- .../Localization/pl.lproj/Localizable.strings | 6 +- .../Localization/pl.lproj/Onboarding.strings | 10 ++-- .../Localization/pt.lproj/Localizable.strings | 6 +- .../Localization/pt.lproj/Onboarding.strings | 10 ++-- .../Localization/ru.lproj/Localizable.strings | 6 +- .../Localization/ru.lproj/Onboarding.strings | 10 ++-- .../Localization/uk.lproj/Localizable.strings | 6 +- .../Localization/uk.lproj/Onboarding.strings | 10 ++-- .../zh-Hans.lproj/Localizable.strings | 6 +- .../zh-Hans.lproj/Onboarding.strings | 10 ++-- MC1/Views/Components/WiFiAddressFields.swift | 55 +++++++++++++++++-- .../Onboarding/WiFiConnectionSheet.swift | 10 +++- .../Settings/Sections/WiFiEditSheet.swift | 10 +++- .../Connection/ConnectionManager+WiFi.swift | 5 ++ .../WiFiTransportError+LocalizedError.swift | 4 +- .../Components/WiFiHostValidationTests.swift | 21 +++++++ docs/guides/WiFi_Transport.md | 8 +-- 30 files changed, 192 insertions(+), 113 deletions(-) create mode 100644 MC1Tests/Views/Components/WiFiHostValidationTests.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 80f970d92..4413eeb1a 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -2630,9 +2630,9 @@ public enum L10n { return L10n.tr("Localizable", "error.wifi.connectionFailed", String(describing: p1), fallback: "Connection failed: %@") } /// Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out - public static let connectionTimeout = L10n.tr("Localizable", "error.wifi.connectionTimeout", fallback: "Connection timed out. Check the IP address and ensure the device is on the same network.") - /// Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid - public static let invalidHost = L10n.tr("Localizable", "error.wifi.invalidHost", fallback: "Invalid IP address.") + public static let connectionTimeout = L10n.tr("Localizable", "error.wifi.connectionTimeout", fallback: "Connection timed out. Check the hostname or IP address and ensure the device is reachable.") + /// Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid + public static let invalidHost = L10n.tr("Localizable", "error.wifi.invalidHost", fallback: "Invalid hostname or IP address.") /// Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid public static let invalidPort = L10n.tr("Localizable", "error.wifi.invalidPort", fallback: "Invalid port number.") /// Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection info missing @@ -3102,7 +3102,7 @@ public enum L10n { public static let title = L10n.tr("Onboarding", "wifiConnection.title", fallback: "Connect via WiFi") public enum ConnectionDetails { /// Location: WiFiConnectionSheet.swift - Footer explaining connection details - public static let footer = L10n.tr("Onboarding", "wifiConnection.connectionDetails.footer", fallback: "Enter your MeshCore device's local network address. The default port is 5000.") + public static let footer = L10n.tr("Onboarding", "wifiConnection.connectionDetails.footer", fallback: "Enter your MeshCore device's hostname or IP address. The default port is 5000.") /// Location: WiFiConnectionSheet.swift - Section header for connection details public static let header = L10n.tr("Onboarding", "wifiConnection.connectionDetails.header", fallback: "Connection Details") } @@ -3111,10 +3111,10 @@ public enum L10n { public static let invalidPort = L10n.tr("Onboarding", "wifiConnection.error.invalidPort", fallback: "Invalid port number") } public enum IpAddress { - /// Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button - public static let clearAccessibility = L10n.tr("Onboarding", "wifiConnection.ipAddress.clearAccessibility", fallback: "Clear IP address") - /// Location: WiFiConnectionSheet.swift - Placeholder for IP address field - public static let placeholder = L10n.tr("Onboarding", "wifiConnection.ipAddress.placeholder", fallback: "IP Address") + /// Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button + public static let clearAccessibility = L10n.tr("Onboarding", "wifiConnection.ipAddress.clearAccessibility", fallback: "Clear hostname or IP") + /// Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field + public static let placeholder = L10n.tr("Onboarding", "wifiConnection.ipAddress.placeholder", fallback: "Hostname or IP") } public enum Port { /// Location: WiFiConnectionSheet.swift - Accessibility label for clear port button diff --git a/MC1/Resources/Localization/de.lproj/Localizable.strings b/MC1/Resources/Localization/de.lproj/Localizable.strings index daa774d30..021ae66d9 100644 --- a/MC1/Resources/Localization/de.lproj/Localizable.strings +++ b/MC1/Resources/Localization/de.lproj/Localizable.strings @@ -328,7 +328,7 @@ "error.wifi.connectionFailed" = "Verbindung fehlgeschlagen: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Zeitüberschreitung bei der Verbindung. Überprüfe die IP-Adresse und stelle sicher, dass sich das Gerät im selben Netzwerk befindet."; +"error.wifi.connectionTimeout" = "Zeitüberschreitung bei der Verbindung. Überprüfe den Hostnamen oder die IP-Adresse und stelle sicher, dass das Gerät erreichbar ist."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Nicht mit dem Gerät verbunden."; @@ -339,8 +339,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Zeitüberschreitung beim Senden."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Ungültige IP-Adresse."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Ungültiger Hostname oder ungültige IP-Adresse."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Ungültige Portnummer."; diff --git a/MC1/Resources/Localization/de.lproj/Onboarding.strings b/MC1/Resources/Localization/de.lproj/Onboarding.strings index 68e336c7c..93c112e1c 100644 --- a/MC1/Resources/Localization/de.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/de.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Verbindungsdetails"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP-Adresse"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Hostname oder IP-Adresse"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "IP-Adresse löschen"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Hostname oder IP-Adresse löschen"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Port"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Port löschen"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Gib die lokale Netzwerkadresse deines MeshCore-Geräts ein. Der Standardport ist 5000."; +"wifiConnection.connectionDetails.footer" = "Gib den Hostnamen oder die IP-Adresse deines MeshCore-Geräts ein. Der Standardport ist 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Verbinde..."; diff --git a/MC1/Resources/Localization/en.lproj/Localizable.strings b/MC1/Resources/Localization/en.lproj/Localizable.strings index 8e2270b4e..fb2bc56f6 100644 --- a/MC1/Resources/Localization/en.lproj/Localizable.strings +++ b/MC1/Resources/Localization/en.lproj/Localizable.strings @@ -335,7 +335,7 @@ "error.wifi.connectionFailed" = "Connection failed: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Connection timed out. Check the IP address and ensure the device is on the same network."; +"error.wifi.connectionTimeout" = "Connection timed out. Check the hostname or IP address and ensure the device is reachable."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Not connected to device."; @@ -346,8 +346,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Send operation timed out."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Invalid IP address."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Invalid hostname or IP address."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Invalid port number."; diff --git a/MC1/Resources/Localization/en.lproj/Onboarding.strings b/MC1/Resources/Localization/en.lproj/Onboarding.strings index 0d1852731..68f401117 100644 --- a/MC1/Resources/Localization/en.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/en.lproj/Onboarding.strings @@ -237,11 +237,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Connection Details"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP Address"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Hostname or IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Clear IP address"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Clear hostname or IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Port"; @@ -250,7 +250,7 @@ "wifiConnection.port.clearAccessibility" = "Clear port"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Enter your MeshCore device's local network address. The default port is 5000."; +"wifiConnection.connectionDetails.footer" = "Enter your MeshCore device's hostname or IP address. The default port is 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Connecting..."; diff --git a/MC1/Resources/Localization/es.lproj/Localizable.strings b/MC1/Resources/Localization/es.lproj/Localizable.strings index 89335b1f6..ce7fba622 100644 --- a/MC1/Resources/Localization/es.lproj/Localizable.strings +++ b/MC1/Resources/Localization/es.lproj/Localizable.strings @@ -332,7 +332,7 @@ "error.wifi.connectionFailed" = "Error de conexión: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Se agotó el tiempo de conexión. Comprueba la dirección IP y asegúrate de que el dispositivo esté en la misma red."; +"error.wifi.connectionTimeout" = "Se agotó el tiempo de conexión. Comprueba el nombre de host o la dirección IP y asegúrate de que el dispositivo sea accesible."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "No conectado al dispositivo."; @@ -343,8 +343,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Se agotó el tiempo del envío."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Dirección IP no válida."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Nombre de host o dirección IP no válidos."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Número de puerto no válido."; diff --git a/MC1/Resources/Localization/es.lproj/Onboarding.strings b/MC1/Resources/Localization/es.lproj/Onboarding.strings index bc116de15..1b28c59c4 100644 --- a/MC1/Resources/Localization/es.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/es.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Detalles de conexión"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "Dirección IP"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Nombre de host o IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Borrar dirección IP"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Borrar nombre de host o IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Puerto"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Borrar puerto"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Introduce la dirección de red local de tu dispositivo MeshCore. El puerto por defecto es 5000."; +"wifiConnection.connectionDetails.footer" = "Introduce el nombre de host o la dirección IP de tu dispositivo MeshCore. El puerto por defecto es 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Conectando..."; diff --git a/MC1/Resources/Localization/fr.lproj/Localizable.strings b/MC1/Resources/Localization/fr.lproj/Localizable.strings index fe6312a58..f2e90bccb 100644 --- a/MC1/Resources/Localization/fr.lproj/Localizable.strings +++ b/MC1/Resources/Localization/fr.lproj/Localizable.strings @@ -338,7 +338,7 @@ "error.wifi.connectionFailed" = "Échec de la connexion : %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "La connexion a expiré. Vérifiez l'adresse IP et assurez-vous que l'appareil est sur le même réseau."; +"error.wifi.connectionTimeout" = "La connexion a expiré. Vérifiez le nom d'hôte ou l'adresse IP et assurez-vous que l'appareil est joignable."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Non connecté à l'appareil."; @@ -349,8 +349,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "L'envoi a expiré."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Adresse IP non valide."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Nom d'hôte ou adresse IP non valide."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Numéro de port non valide."; diff --git a/MC1/Resources/Localization/fr.lproj/Onboarding.strings b/MC1/Resources/Localization/fr.lproj/Onboarding.strings index 92f762eb5..5b06be7e2 100644 --- a/MC1/Resources/Localization/fr.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/fr.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Détails de connexion"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "Adresse IP"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Nom d'hôte ou IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Effacer l'adresse IP"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Effacer le nom d'hôte ou l'adresse IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Port"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Effacer le port"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Entrez l'adresse réseau locale de votre appareil MeshCore. Le port par défaut est 5000."; +"wifiConnection.connectionDetails.footer" = "Entrez le nom d'hôte ou l'adresse IP de votre appareil MeshCore. Le port par défaut est 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Connexion..."; diff --git a/MC1/Resources/Localization/it.lproj/Localizable.strings b/MC1/Resources/Localization/it.lproj/Localizable.strings index d5ec52c33..cefaff6bf 100644 --- a/MC1/Resources/Localization/it.lproj/Localizable.strings +++ b/MC1/Resources/Localization/it.lproj/Localizable.strings @@ -334,7 +334,7 @@ "error.wifi.connectionFailed" = "Connessione non riuscita: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Connessione scaduta. Controlla l'indirizzo IP e assicurati che il dispositivo sia nella stessa rete."; +"error.wifi.connectionTimeout" = "Connessione scaduta. Controlla il nome host o l'indirizzo IP e assicurati che il dispositivo sia raggiungibile."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Non connesso al dispositivo."; @@ -345,8 +345,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Operazione di invio scaduta."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Indirizzo IP non valido."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Nome host o indirizzo IP non valido."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Numero di porta non valido."; diff --git a/MC1/Resources/Localization/it.lproj/Onboarding.strings b/MC1/Resources/Localization/it.lproj/Onboarding.strings index fd691974a..a1d847ae0 100644 --- a/MC1/Resources/Localization/it.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/it.lproj/Onboarding.strings @@ -237,11 +237,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Dettagli connessione"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "Indirizzo IP"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Nome host o indirizzo IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Cancella indirizzo IP"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Cancella nome host o indirizzo IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Porta"; @@ -250,7 +250,7 @@ "wifiConnection.port.clearAccessibility" = "Cancella porta"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Inserisci l'indirizzo di rete locale del tuo dispositivo MeshCore. La porta predefinita è 5000."; +"wifiConnection.connectionDetails.footer" = "Inserisci il nome host o l'indirizzo IP del tuo dispositivo MeshCore. La porta predefinita è 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Connessione..."; diff --git a/MC1/Resources/Localization/nl.lproj/Localizable.strings b/MC1/Resources/Localization/nl.lproj/Localizable.strings index 59f9f9044..4285fc1d6 100644 --- a/MC1/Resources/Localization/nl.lproj/Localizable.strings +++ b/MC1/Resources/Localization/nl.lproj/Localizable.strings @@ -339,7 +339,7 @@ "error.wifi.connectionFailed" = "Verbinding mislukt: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "De verbinding duurde te lang. Controleer het IP-adres en zorg dat het apparaat zich in hetzelfde netwerk bevindt."; +"error.wifi.connectionTimeout" = "De verbinding duurde te lang. Controleer de hostnaam of het IP-adres en zorg dat het apparaat bereikbaar is."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Niet verbonden met het apparaat."; @@ -350,8 +350,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Het verzenden duurde te lang."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Ongeldig IP-adres."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Ongeldige hostnaam of IP-adres."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Ongeldig poortnummer."; diff --git a/MC1/Resources/Localization/nl.lproj/Onboarding.strings b/MC1/Resources/Localization/nl.lproj/Onboarding.strings index 409ad76c6..64d6fface 100644 --- a/MC1/Resources/Localization/nl.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/nl.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Verbindingsgegevens"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP-adres"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Hostnaam of IP-adres"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "IP-adres wissen"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Hostnaam of IP-adres wissen"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Poort"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Poort wissen"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Voer het lokale netwerkadres van je MeshCore-apparaat in. De standaardpoort is 5000."; +"wifiConnection.connectionDetails.footer" = "Voer de hostnaam of het IP-adres van je MeshCore-apparaat in. De standaardpoort is 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Verbinden..."; diff --git a/MC1/Resources/Localization/pl.lproj/Localizable.strings b/MC1/Resources/Localization/pl.lproj/Localizable.strings index 78e93be8e..f44806923 100644 --- a/MC1/Resources/Localization/pl.lproj/Localizable.strings +++ b/MC1/Resources/Localization/pl.lproj/Localizable.strings @@ -337,7 +337,7 @@ "error.wifi.connectionFailed" = "Połączenie nie powiodło się: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Upłynął limit czasu połączenia. Sprawdź adres IP i upewnij się, że urządzenie jest w tej samej sieci."; +"error.wifi.connectionTimeout" = "Upłynął limit czasu połączenia. Sprawdź nazwę hosta lub adres IP i upewnij się, że urządzenie jest osiągalne."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Brak połączenia z urządzeniem."; @@ -348,8 +348,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Upłynął limit czasu wysyłania."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Nieprawidłowy adres IP."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Nieprawidłowa nazwa hosta lub nieprawidłowy adres IP."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Nieprawidłowy numer portu."; diff --git a/MC1/Resources/Localization/pl.lproj/Onboarding.strings b/MC1/Resources/Localization/pl.lproj/Onboarding.strings index 5e62b00a8..69674e564 100644 --- a/MC1/Resources/Localization/pl.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/pl.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Szczegóły połączenia"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "Adres IP"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Nazwa hosta lub adres IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Wyczyść adres IP"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Wyczyść nazwę hosta lub adres IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Port"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Wyczyść port"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Wprowadź adres sieci lokalnej urządzenia MeshCore. Domyślny port to 5000."; +"wifiConnection.connectionDetails.footer" = "Wprowadź nazwę hosta lub adres IP swojego urządzenia MeshCore. Domyślny port to 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Łączenie..."; diff --git a/MC1/Resources/Localization/pt.lproj/Localizable.strings b/MC1/Resources/Localization/pt.lproj/Localizable.strings index 766abed0a..723bb6dfd 100644 --- a/MC1/Resources/Localization/pt.lproj/Localizable.strings +++ b/MC1/Resources/Localization/pt.lproj/Localizable.strings @@ -334,7 +334,7 @@ "error.wifi.connectionFailed" = "Falha na ligação: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "A ligação expirou. Verifique o endereço IP e certifique-se de que o dispositivo está na mesma rede."; +"error.wifi.connectionTimeout" = "A ligação expirou. Verifique o hostname ou o endereço IP e certifique-se de que o dispositivo está acessível."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Não ligado ao dispositivo."; @@ -345,8 +345,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "A operação de envio expirou."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Endereço IP inválido."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Hostname ou endereço IP inválido."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Número de porta inválido."; diff --git a/MC1/Resources/Localization/pt.lproj/Onboarding.strings b/MC1/Resources/Localization/pt.lproj/Onboarding.strings index 7271c9ba9..7b6beb8a5 100644 --- a/MC1/Resources/Localization/pt.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/pt.lproj/Onboarding.strings @@ -237,11 +237,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Detalhes da ligação"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "Endereço IP"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Hostname ou IP"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Limpar endereço IP"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Limpar hostname ou IP"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Porta"; @@ -250,7 +250,7 @@ "wifiConnection.port.clearAccessibility" = "Limpar porta"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Introduza o endereço de rede local do dispositivo MeshCore. A porta predefinida é 5000."; +"wifiConnection.connectionDetails.footer" = "Introduza o hostname ou o endereço IP do dispositivo MeshCore. A porta predefinida é 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "A ligar..."; diff --git a/MC1/Resources/Localization/ru.lproj/Localizable.strings b/MC1/Resources/Localization/ru.lproj/Localizable.strings index c348ba459..11c8ab3ef 100644 --- a/MC1/Resources/Localization/ru.lproj/Localizable.strings +++ b/MC1/Resources/Localization/ru.lproj/Localizable.strings @@ -336,7 +336,7 @@ "error.wifi.connectionFailed" = "Не удалось подключиться: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Время ожидания подключения истекло. Проверьте IP-адрес и убедитесь, что устройство находится в той же сети."; +"error.wifi.connectionTimeout" = "Время ожидания подключения истекло. Проверьте имя хоста или IP-адрес и убедитесь, что устройство доступно."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Нет подключения к устройству."; @@ -347,8 +347,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Время ожидания отправки истекло."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Недопустимый IP-адрес."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Недопустимое имя хоста или IP-адрес."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Недопустимый номер порта."; diff --git a/MC1/Resources/Localization/ru.lproj/Onboarding.strings b/MC1/Resources/Localization/ru.lproj/Onboarding.strings index 8c31f5eea..b6a490b23 100644 --- a/MC1/Resources/Localization/ru.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/ru.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Данные подключения"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP-адрес"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Имя хоста или IP-адрес"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Очистить IP-адрес"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Очистить имя хоста или IP-адрес"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Порт"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Очистить порт"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Введите адрес локальной сети вашего устройства MeshCore. Порт по умолчанию: 5000."; +"wifiConnection.connectionDetails.footer" = "Введите имя хоста или IP-адрес устройства MeshCore. Порт по умолчанию — 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Подключение..."; diff --git a/MC1/Resources/Localization/uk.lproj/Localizable.strings b/MC1/Resources/Localization/uk.lproj/Localizable.strings index 1d638e3cf..047c29385 100644 --- a/MC1/Resources/Localization/uk.lproj/Localizable.strings +++ b/MC1/Resources/Localization/uk.lproj/Localizable.strings @@ -339,7 +339,7 @@ "error.wifi.connectionFailed" = "Не вдалося під'єднатися: %@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "Час очікування з'єднання вичерпано. Перевірте IP-адресу та переконайтеся, що пристрій у тій самій мережі."; +"error.wifi.connectionTimeout" = "Час очікування з'єднання вичерпано. Перевірте ім'я хоста або IP-адресу та переконайтеся, що пристрій доступний."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "Немає з'єднання з пристроєм."; @@ -350,8 +350,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "Час очікування надсилання вичерпано."; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "Неприпустима IP-адреса."; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "Неприпустиме ім'я хоста або IP-адреса."; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "Неприпустимий номер порту."; diff --git a/MC1/Resources/Localization/uk.lproj/Onboarding.strings b/MC1/Resources/Localization/uk.lproj/Onboarding.strings index 39565bf9a..f14ed284e 100644 --- a/MC1/Resources/Localization/uk.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/uk.lproj/Onboarding.strings @@ -235,11 +235,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "Деталі підключення"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP-адреса"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "Ім'я хоста або IP-адреса"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "Очистити IP-адресу"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "Очистити ім'я хоста або IP-адресу"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "Порт"; @@ -248,7 +248,7 @@ "wifiConnection.port.clearAccessibility" = "Очистити порт"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "Введіть адресу локальної мережі вашого пристрою MeshCore. Порт за замовчуванням: 5000."; +"wifiConnection.connectionDetails.footer" = "Введіть ім'я хоста або IP-адресу пристрою MeshCore. Порт за замовчуванням: 5000."; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "Підключення..."; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings b/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings index fdf70d289..525ca44eb 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Localizable.strings @@ -335,7 +335,7 @@ "error.wifi.connectionFailed" = "连接失败:%@"; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi connection attempt timed out */ -"error.wifi.connectionTimeout" = "连接超时。请检查 IP 地址,并确保设备在同一网络中。"; +"error.wifi.connectionTimeout" = "连接超时。请检查主机名或 IP 地址,并确保可以连接到设备。"; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ "error.wifi.notConnected" = "未连接到设备。"; @@ -346,8 +346,8 @@ /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send timed out */ "error.wifi.sendTimeout" = "发送操作超时。"; -/* Location: WiFiTransportError+UserFacingMessage.swift - Configured IP address is invalid */ -"error.wifi.invalidHost" = "IP 地址无效。"; +/* Location: WiFiTransportError+UserFacingMessage.swift - Configured hostname or IP address is invalid */ +"error.wifi.invalidHost" = "主机名或 IP 地址无效。"; /* Location: WiFiTransportError+UserFacingMessage.swift - Configured port number is invalid */ "error.wifi.invalidPort" = "端口号无效。"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings b/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings index 202b528bb..9c8facb09 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Onboarding.strings @@ -232,11 +232,11 @@ /* Location: WiFiConnectionSheet.swift - Section header for connection details */ "wifiConnection.connectionDetails.header" = "连接详情"; -/* Location: WiFiConnectionSheet.swift - Placeholder for IP address field */ -"wifiConnection.ipAddress.placeholder" = "IP 地址"; +/* Location: WiFiConnectionSheet.swift - Placeholder for hostname or IP address field */ +"wifiConnection.ipAddress.placeholder" = "主机名或 IP 地址"; -/* Location: WiFiConnectionSheet.swift - Accessibility label for clear IP button */ -"wifiConnection.ipAddress.clearAccessibility" = "清除 IP 地址"; +/* Location: WiFiConnectionSheet.swift - Accessibility label for clear hostname or IP button */ +"wifiConnection.ipAddress.clearAccessibility" = "清除主机名或 IP 地址"; /* Location: WiFiConnectionSheet.swift - Placeholder for port field */ "wifiConnection.port.placeholder" = "端口"; @@ -245,7 +245,7 @@ "wifiConnection.port.clearAccessibility" = "清除端口"; /* Location: WiFiConnectionSheet.swift - Footer explaining connection details */ -"wifiConnection.connectionDetails.footer" = "输入您的 MeshCore 设备的本地网络地址。默认端口为 5000。"; +"wifiConnection.connectionDetails.footer" = "输入您的 MeshCore 设备的主机名或 IP 地址。默认端口为 5000。"; /* Location: WiFiConnectionSheet.swift - Button label while connecting */ "wifiConnection.connecting" = "连接中..."; diff --git a/MC1/Views/Components/WiFiAddressFields.swift b/MC1/Views/Components/WiFiAddressFields.swift index 1aafeedc7..2f85866db 100644 --- a/MC1/Views/Components/WiFiAddressFields.swift +++ b/MC1/Views/Components/WiFiAddressFields.swift @@ -4,7 +4,15 @@ enum WiFiField: Hashable { case ipAddress, port } -/// Shared IP address and port input fields used by WiFi connection sheets. +private enum WiFiHostLimits { + static let hostnameMaxLength = 253 + static let hostnameLabelMaxLength = 63 + static let hostnameLabelCharacters = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-" + ) +} + +/// Shared host and port input fields used by WiFi connection sheets. struct WiFiAddressFields: View { @Binding var ipAddress: String @Binding var port: String @@ -23,8 +31,7 @@ struct WiFiAddressFields: View { Section { HStack { TextField(L10n.Onboarding.WifiConnection.IpAddress.placeholder, text: $ipAddress) - .keyboardType(usesFullKeyboardInput ? .numbersAndPunctuation : .decimalPad) - .environment(\.locale, Locale(identifier: "en_US")) + .keyboardType(.URL) .textContentType(.none) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -81,7 +88,21 @@ struct WiFiAddressFields: View { // MARK: - Validation - static func isValidIPAddress(_ ip: String) -> Bool { + /// Host checks do not touch view state, so they stay off the main actor. + nonisolated static func isValidHost(_ raw: String) -> Bool { + let host = normalizedHost(raw) + guard !host.isEmpty else { return false } + if looksLikeIPv4(host) { + return isValidIPAddress(host) + } + return isValidHostname(host) + } + + nonisolated static func normalizedHost(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + nonisolated static func isValidIPAddress(_ ip: String) -> Bool { let parts = ip.split(separator: ".") guard parts.count == 4 else { return false } return parts.allSatisfy { part in @@ -90,8 +111,32 @@ struct WiFiAddressFields: View { } } - static func isValidPort(_ port: String) -> Bool { + nonisolated static func isValidPort(_ port: String) -> Bool { guard let num = UInt16(port) else { return false } return num > 0 } + + private nonisolated static func looksLikeIPv4(_ host: String) -> Bool { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard !parts.isEmpty else { return false } + return parts.allSatisfy { part in + !part.isEmpty && part.allSatisfy { $0.isASCII && $0.isNumber } + } + } + + private nonisolated static func isValidHostname(_ host: String) -> Bool { + let candidate = host.hasSuffix(".") ? String(host.dropLast()) : host + guard (1...WiFiHostLimits.hostnameMaxLength).contains(candidate.count) else { return false } + let labels = candidate.split(separator: ".", omittingEmptySubsequences: false) + return !labels.isEmpty && labels.allSatisfy(isValidHostnameLabel) + } + + private nonisolated static func isValidHostnameLabel(_ label: Substring) -> Bool { + guard (1...WiFiHostLimits.hostnameLabelMaxLength).contains(label.count) else { return false } + guard let first = label.first, let last = label.last else { return false } + guard label.unicodeScalars.allSatisfy({ WiFiHostLimits.hostnameLabelCharacters.contains($0) }) else { + return false + } + return first != "-" && last != "-" + } } diff --git a/MC1/Views/Onboarding/WiFiConnectionSheet.swift b/MC1/Views/Onboarding/WiFiConnectionSheet.swift index b510c58a9..9fb19ad30 100644 --- a/MC1/Views/Onboarding/WiFiConnectionSheet.swift +++ b/MC1/Views/Onboarding/WiFiConnectionSheet.swift @@ -68,7 +68,7 @@ private func ipv6AddressesOfBroadcastCapableInterfaces() -> [sockaddr_in6] { } } -/// Sheet for entering WiFi connection details (IP address and port). +/// Sheet for entering WiFi connection details (hostname or IP address and port). struct WiFiConnectionSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.appState) private var appState @@ -82,7 +82,7 @@ struct WiFiConnectionSheet: View { @FocusState private var focusedField: WiFiField? private var isValidInput: Bool { - WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) + WiFiAddressFields.isValidHost(ipAddress) && WiFiAddressFields.isValidPort(port) } private var usesFullKeyboardInput: Bool { @@ -154,7 +154,11 @@ struct WiFiConnectionSheet: View { Task { do { - try await appState.connectViaWiFi(host: ipAddress, port: portNumber, forceFullSync: true) + try await appState.connectViaWiFi( + host: WiFiAddressFields.normalizedHost(ipAddress), + port: portNumber, + forceFullSync: true + ) await appState.wireServicesIfConnected() dismiss() // Navigate directly to radio settings diff --git a/MC1/Views/Settings/Sections/WiFiEditSheet.swift b/MC1/Views/Settings/Sections/WiFiEditSheet.swift index e6453faf8..7cc5892e2 100644 --- a/MC1/Views/Settings/Sections/WiFiEditSheet.swift +++ b/MC1/Views/Settings/Sections/WiFiEditSheet.swift @@ -36,12 +36,12 @@ struct WiFiEditSheet: View { } private var isValidInput: Bool { - WiFiAddressFields.isValidIPAddress(ipAddress) && WiFiAddressFields.isValidPort(port) + WiFiAddressFields.isValidHost(ipAddress) && WiFiAddressFields.isValidPort(port) } private var hasChanges: Bool { guard let host = originalHost, let currentPort = originalPort else { return true } - return ipAddress != host || port != String(currentPort) + return WiFiAddressFields.normalizedHost(ipAddress) != host || port != String(currentPort) } var body: some View { @@ -120,7 +120,11 @@ struct WiFiEditSheet: View { 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) + try await appState.connectViaWiFi( + host: WiFiAddressFields.normalizedHost(ipAddress), + port: portNumber, + forceFullSync: true + ) dismiss() } catch { errorMessage = error.userFacingMessage diff --git a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift index a45295399..caa4d36c5 100644 --- a/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift +++ b/MC1Services/Sources/MC1Services/Connection/ConnectionManager+WiFi.swift @@ -312,6 +312,11 @@ extension ConnectionManager { /// - 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 { + let host = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty else { + throw WiFiTransportError.invalidHost + } + logger.info("Connecting via WiFi to \(host):\(port)") // Disconnect existing connection if any diff --git a/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift b/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift index 1d001be6b..7fdb35adf 100644 --- a/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift +++ b/MC1Services/Sources/MC1Services/Errors/WiFiTransportError+LocalizedError.swift @@ -9,7 +9,7 @@ extension WiFiTransportError: @retroactive LocalizedError { 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." + "Connection timed out. Check the hostname or IP address and ensure the device is reachable." case .notConnected: "Not connected to device." case let .sendFailed(reason): @@ -17,7 +17,7 @@ extension WiFiTransportError: @retroactive LocalizedError { case .sendTimeout: "Send operation timed out." case .invalidHost: - "Invalid IP address." + "Invalid hostname or IP address." case .invalidPort: "Invalid port number." case .notConfigured: diff --git a/MC1Tests/Views/Components/WiFiHostValidationTests.swift b/MC1Tests/Views/Components/WiFiHostValidationTests.swift new file mode 100644 index 000000000..6b10dadc3 --- /dev/null +++ b/MC1Tests/Views/Components/WiFiHostValidationTests.swift @@ -0,0 +1,21 @@ +import Foundation +@testable import MC1 +import Testing + +@Suite("WiFi host validation") +struct WiFiHostValidationTests { + @Test(arguments: [ + ("192.168.1.50", true), + ("radio.local", true), + ("example.com", true), + ("repeater", true), + (" radio.local ", true), + ("", false), + ("999.999.999.999", false), + ("192.168.1", false), + ("http://radio.local", false), + ]) + func `classifies host`(_ host: String, _ expected: Bool) { + #expect(WiFiAddressFields.isValidHost(host) == expected) + } +} diff --git a/docs/guides/WiFi_Transport.md b/docs/guides/WiFi_Transport.md index 2c55a4116..8ed65b12f 100644 --- a/docs/guides/WiFi_Transport.md +++ b/docs/guides/WiFi_Transport.md @@ -9,7 +9,7 @@ MeshCore One supports two transport types: - BLE (Bluetooth Low Energy) via `iOSBLETransport` in MC1Services - WiFi/TCP via `WiFiTransport` in MeshCore -WiFi is configured manually (host + port) and is typically used for fixed installations or devices that expose a TCP service. +WiFi is configured manually (hostname or IPv4 address + port) and is typically used for fixed installations or devices that expose a TCP service. Network.framework resolves the host at connect time, including mDNS `.local` names. The transport does not browse Bonjour services. ## Where the Code Lives @@ -45,7 +45,7 @@ The framed payload is the same MeshCore binary protocol payload used over BLE. import MeshCore let transport = WiFiTransport() -await transport.setConnectionInfo(host: "192.168.1.50", port: 5000) +await transport.setConnectionInfo(host: "radio.local", port: 5000) try await transport.connect() let session = MeshCoreSession(transport: transport) @@ -54,7 +54,7 @@ try await session.start() Notes: -- The transport itself does not implement discovery (mDNS/Bonjour) or keep-alives. +- The transport itself does not implement discovery (mDNS/Bonjour browsing) or keep-alives. Connecting to a hostname still uses Network.framework DNS, including mDNS for `.local`. - Reconnect behavior is handled at higher layers (e.g., `ConnectionManager`). - Use `Logger` for diagnostics; avoid `print()`. @@ -71,7 +71,7 @@ MeshCore One manages WiFi reconnection and connection health at the app layer: ## Troubleshooting - Verify the iPhone and device are on the same reachable network. -- Double check the host and port (MeshCore One defaults to port 5000 in the WiFi connection UI). +- Double check the hostname or IP and port (MeshCore One defaults to port 5000 in the WiFi connection UI). - If you have a dev machine on the same network, `nc -zv ` can help validate basic reachability. ## Further Reading From 5813a4732ad04aeda5555093c75a1fdc7525718e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:37:31 -0700 Subject: [PATCH 30/47] fix(make): show progress while tests run - xcsift prints one summary at EOF, so make test-app looked hung after xcodegen - heartbeat from the pipe consumer every 30s; lock wait lines include elapsed time --- Makefile | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 8a420994c..11681daf8 100644 --- a/Makefile +++ b/Makefile @@ -51,15 +51,33 @@ STORE_SUITES := \ # wedge every later run. The calling recipe sets `dest` first. SIM_LOCK = if [ -z "$$CI" ]; then \ lock="/tmp/mc1-xcodebuild-$$(printf '%s' "$$dest" | tr -c 'A-Za-z0-9' '-').lock"; \ + waited=0; \ while :; do \ if ( set -C; echo $$$$ > "$$lock" ) 2>/dev/null; then break; fi; \ owner=$$(cat "$$lock" 2>/dev/null); \ if [ -z "$$owner" ] || ! ps -p "$$owner" >/dev/null 2>&1; then rm -f "$$lock"; continue; fi; \ - echo "==> waiting for simulator lock ($$dest), held by pid $$owner"; sleep 2; \ + echo "==> waiting for simulator lock ($$dest), held by pid $$owner, $${waited}s elapsed"; sleep 2; \ + waited=$$((waited + 2)); \ done; \ trap 'rm -f "$$lock"' EXIT INT TERM HUP; \ fi +# Heartbeat runs in the pipe consumer so its trap cannot replace SIM_LOCK's +# lock-release trap. Kill it after xcsift; macOS bash skips pipeline exit traps. +XCODEBUILD_HEARTBEAT_SEC ?= 30 +XCSIFT_WITH_HEARTBEAT = { \ + echo "==> xcodebuild test ($$dest); xcsift summary at EOF" >&2; \ + SECONDS=0; \ + ( while sleep $(XCODEBUILD_HEARTBEAT_SEC); do echo "==> still running $${SECONDS}s" >&2; done ) & \ + hb=$$!; \ + trap 'kill $$hb 2>/dev/null || true' EXIT; \ + xcsift -f toon; \ + sift=$$?; \ + kill $$hb 2>/dev/null || true; \ + wait $$hb 2>/dev/null || true; \ + exit $$sift; \ + } + .DEFAULT_GOAL := help .PHONY: help generate test test-app test-store # `make test` runs two xcodebuild passes because a single invocation targets one OS: the full @@ -91,9 +109,9 @@ test: test-app test-store ## Run everything: full app suite (iOS 26) + StoreKit test-app: generate ## Run the full app suite on iOS 26 (StoreKit suites auto-skip here) @dest='$(SIM)'; $(SIM_LOCK); \ xcodebuild test -project $(PROJECT) -scheme $(SCHEME) \ - -destination "$$dest" 2>&1 | xcsift -f toon + -destination "$$dest" 2>&1 | $(XCSIFT_WITH_HEARTBEAT) test-store: generate ## Run every StoreKit/IAP SKTestSession suite on iOS 18.x @dest='$(STORE_SIM)'; $(SIM_LOCK); \ xcodebuild test -project $(PROJECT) -scheme $(SCHEME) \ - -destination "$$dest" $(STORE_SUITES) 2>&1 | xcsift -f toon + -destination "$$dest" $(STORE_SUITES) 2>&1 | $(XCSIFT_WITH_HEARTBEAT) From 417690bcf6b5531f3b8e86ee93a418ba0d78521f Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:27:30 -0700 Subject: [PATCH 31/47] fix(chats): cap first-page size on huge unread A busy conversation sized the first fetch to every unread plus context, which could load thousands of rows on open. Cap that window at 200; remaining unread pages in via loadOlder. --- .../Services/ChatCoordinator.swift | 13 ++-- .../Services/ChatCoordinatorTests.swift | 12 +++- .../ChatViewModelPaginationTests.swift | 63 +++++++++++++++++-- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift index 1a02ca017..794a07491 100644 --- a/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift +++ b/MC1Services/Sources/MC1Services/Services/ChatCoordinator.swift @@ -33,13 +33,14 @@ public final class ChatCoordinator { /// has a little context to sit beneath rather than pinning to the very top. public static let dividerReadContext: Int = 12 - /// Initial fetch size for opening a conversation. Guarantees every unread - /// message (plus a little read context) lands in the first page: otherwise a - /// conversation with more than `pageSize` unread would place the divider on a - /// message that only pages in later, leaving the jump-to-divider button with no - /// materialized target to scroll to. + /// Ceiling on the first-page fetch. Remaining unread pages in via `loadOlder`; + /// the divider stays on the oldest row of this window and is not recomputed. + public static let maxInitialPageSize: Int = 200 + + /// Initial fetch size: at least `pageSize`, enough unread plus read context + /// to land the divider when that fits, otherwise `maxInitialPageSize`. public static func initialPageSize(unreadCount: Int) -> Int { - max(pageSize, unreadCount + dividerReadContext) + min(max(pageSize, unreadCount + dividerReadContext), maxInitialPageSize) } public let conversationID: ChatConversationID diff --git a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift index e3dbd4f85..406bfac38 100644 --- a/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/Services/ChatCoordinatorTests.swift @@ -16,12 +16,18 @@ struct ChatCoordinatorTests { @Test func `initialPageSize grows to cover all unread plus read context`() { - // Unread beyond one page must all load at once, else the first-unread message - // (where the divider sits) would only page in later and the jump would have no target. + // Under the cap, unread beyond one page loads at once so the divider has a + // materialized target. Past maxInitialPageSize the fetch clamps. let unread = ChatCoordinator.pageSize + 70 let limit = ChatCoordinator.initialPageSize(unreadCount: unread) #expect(limit == unread + ChatCoordinator.dividerReadContext) - #expect(limit > unread, "Every unread message plus context must fit in the first page") + #expect(limit > unread, "Under the cap, unread plus context must fit in the first page") + } + + @Test + func `initialPageSize caps a huge unread backlog`() { + let unread = ChatCoordinator.maxInitialPageSize * 10 + #expect(ChatCoordinator.initialPageSize(unreadCount: unread) == ChatCoordinator.maxInitialPageSize) } @Test diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index aca76a2d3..f98e13a8a 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -217,10 +217,8 @@ struct ChatViewModelPaginationTests { struct ChatViewModelChannelPaginationTests { @Test func `Opening a channel with more unread than one page loads the divider target`() async throws { - // When unread exceeds pageSize, the first-unread message (where the divider - // belongs) is older than a standard page. The initial load must be sized to - // cover all unread so the divider has a materialized message to scroll to, - // instead of clamping the divider onto the oldest loaded row. + // Unread past pageSize but under maxInitialPageSize: grow the first page so + // the divider lands on the true first-unread row, not the oldest loaded. let container = try PersistenceStore.createContainer(inMemory: true) let dataStore = PersistenceStore(modelContainer: container) let radioID = UUID() @@ -270,7 +268,7 @@ struct ChatViewModelChannelPaginationTests { await viewModel.loadChannelMessages(for: channel, populateMode: .replace) #expect(viewModel.messages.count == ChatCoordinator.initialPageSize(unreadCount: unread), - "Initial load must fetch all unread plus read context, not just one page") + "Under the cap, initial load must fetch unread plus read context, not one page") #expect(viewModel.bake.newMessagesDividerMessageID == expectedDividerID, "Divider must land on the true first-unread message") #expect(viewModel.messages.contains { $0.id == expectedDividerID }, @@ -279,6 +277,61 @@ struct ChatViewModelChannelPaginationTests { "Newest message must still be loaded") } + @Test + func `Opening a channel with unread past the initial-page cap loads the newest window only`() async throws { + // Past the cap only the newest window loads. The divider clamps to the oldest + // loaded row and stays there; remaining unread sits above it after loadOlder. + let container = try PersistenceStore.createContainer(inMemory: true) + let dataStore = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channelIndex: UInt8 = 0 + let total = ChatCoordinator.maxInitialPageSize + 100 + let unread = ChatCoordinator.maxInitialPageSize + 50 + #expect(unread > ChatCoordinator.maxInitialPageSize) + + let channel = ChannelDTO( + id: UUID(), + radioID: radioID, + index: channelIndex, + name: "Public Channel", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: unread, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + try await dataStore.saveChannel(channel) + + var idsOldestFirst: [UUID] = [] + for index in 0.. Date: Mon, 24 Aug 2026 08:26:29 -0700 Subject: [PATCH 32/47] fix(make): skip simctl diagnose after tests - xcodebuild can run simctl diagnose (--timeout=600) after a green suite - pass -collect-test-diagnostics never on test-app and test-store --- Makefile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 11681daf8..5cbf1b6b8 100644 --- a/Makefile +++ b/Makefile @@ -106,12 +106,18 @@ generate: dev.yml ## Regenerate MC1.xcodeproj from project.yml (xcodegen) test: test-app test-store ## Run everything: full app suite (iOS 26) + StoreKit suites (iOS 18) +# Skip sysdiagnose collection. xcodebuild can spawn simctl diagnose after the +# suite and block the recipe for minutes even when every test passed. test-app: generate ## Run the full app suite on iOS 26 (StoreKit suites auto-skip here) @dest='$(SIM)'; $(SIM_LOCK); \ xcodebuild test -project $(PROJECT) -scheme $(SCHEME) \ - -destination "$$dest" 2>&1 | $(XCSIFT_WITH_HEARTBEAT) + -destination "$$dest" \ + -collect-test-diagnostics never \ + 2>&1 | $(XCSIFT_WITH_HEARTBEAT) test-store: generate ## Run every StoreKit/IAP SKTestSession suite on iOS 18.x @dest='$(STORE_SIM)'; $(SIM_LOCK); \ xcodebuild test -project $(PROJECT) -scheme $(SCHEME) \ - -destination "$$dest" $(STORE_SUITES) 2>&1 | $(XCSIFT_WITH_HEARTBEAT) + -destination "$$dest" $(STORE_SUITES) \ + -collect-test-diagnostics never \ + 2>&1 | $(XCSIFT_WITH_HEARTBEAT) From 8501669650f1132910eaf1500e914396ede69065 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:37:35 -0700 Subject: [PATCH 33/47] feat(chats): show failed-send badge on list rows - Add a list indicator for unseen outgoing .failed sends - Mark them seen when the conversation is opened --- MC1/Resources/Generated/L10n.swift | 2 + .../Localization/de.lproj/Chats.strings | 3 + .../Localization/en.lproj/Chats.strings | 3 + .../Localization/es.lproj/Chats.strings | 3 + .../Localization/fr.lproj/Chats.strings | 3 + .../Localization/it.lproj/Chats.strings | 3 + .../Localization/nl.lproj/Chats.strings | 3 + .../Localization/pl.lproj/Chats.strings | 3 + .../Localization/pt.lproj/Chats.strings | 3 + .../Localization/ru.lproj/Chats.strings | 3 + .../Localization/uk.lproj/Chats.strings | 3 + .../Localization/zh-Hans.lproj/Chats.strings | 3 + MC1/Views/Chats/ChannelConversationRow.swift | 4 + MC1/Views/Chats/ChatsListModifiers.swift | 7 + MC1/Views/Chats/ConversationListContent.swift | 2 +- MC1/Views/Chats/ConversationRow.swift | 4 + MC1/Views/Chats/FailedSendIndicator.swift | 187 +++++ .../Chats/Room/RoomConversationRow.swift | 5 + .../ViewModel/ChatViewModel+Channels.swift | 11 +- .../ChatViewModel+ConversationList.swift | 62 ++ .../ViewModel/ChatViewModel+EventStream.swift | 42 +- .../ChatViewModel+MessageActions.swift | 5 +- .../ViewModel/ChatViewModel+Messages.swift | 6 +- MC1/Views/Chats/ViewModel/ChatViewModel.swift | 16 + .../Rooms/RoomConversationViewModel.swift | 10 + .../Models/FailedSendConversationKeys.swift | 21 + .../Sources/MC1Services/Models/Message.swift | 16 +- .../MC1Services/Models/RoomMessage.swift | 36 +- .../PersistenceStore+FailedSends.swift | 131 ++++ .../Services/PersistenceStore+Messages.swift | 6 + .../Services/PersistenceStore+Rooms.swift | 6 + .../BackupIntegrationTests.swift | 70 ++ .../FailedSendConversationKeysTests.swift | 328 +++++++++ .../Helpers/RoomMessageDTO+Testing.swift | 6 +- .../Chats/ChatViewModelFailedSendTests.swift | 643 ++++++++++++++++++ 35 files changed, 1635 insertions(+), 24 deletions(-) create mode 100644 MC1/Views/Chats/FailedSendIndicator.swift create mode 100644 MC1Services/Sources/MC1Services/Models/FailedSendConversationKeys.swift create mode 100644 MC1Services/Sources/MC1Services/Services/PersistenceStore+FailedSends.swift create mode 100644 MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift create mode 100644 MC1Tests/Views/Chats/ChatViewModelFailedSendTests.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 4413eeb1a..51316fa38 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -941,6 +941,8 @@ public enum L10n { } } public enum Row { + /// Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator + public static let failedSend = L10n.tr("Chats", "chats.row.failedSend", fallback: "Failed to send") /// Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for favorite indicator public static let favorite = L10n.tr("Chats", "chats.row.favorite", fallback: "Favorite") /// Location: MutedIndicator.swift - Accessibility label for mentions-only indicator diff --git a/MC1/Resources/Localization/de.lproj/Chats.strings b/MC1/Resources/Localization/de.lproj/Chats.strings index feca69582..9687d42d2 100644 --- a/MC1/Resources/Localization/de.lproj/Chats.strings +++ b/MC1/Resources/Localization/de.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Noch keine Nachrichten"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Senden fehlgeschlagen"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Stumm"; diff --git a/MC1/Resources/Localization/en.lproj/Chats.strings b/MC1/Resources/Localization/en.lproj/Chats.strings index 3ba06c1ec..46e53fb43 100644 --- a/MC1/Resources/Localization/en.lproj/Chats.strings +++ b/MC1/Resources/Localization/en.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "No messages yet"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Failed to send"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Muted"; diff --git a/MC1/Resources/Localization/es.lproj/Chats.strings b/MC1/Resources/Localization/es.lproj/Chats.strings index e79fb66ec..5161a26b2 100644 --- a/MC1/Resources/Localization/es.lproj/Chats.strings +++ b/MC1/Resources/Localization/es.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Sin mensajes aún"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Error al enviar"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Silenciado"; diff --git a/MC1/Resources/Localization/fr.lproj/Chats.strings b/MC1/Resources/Localization/fr.lproj/Chats.strings index 3dded2494..c77589c38 100644 --- a/MC1/Resources/Localization/fr.lproj/Chats.strings +++ b/MC1/Resources/Localization/fr.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Aucun message pour l'instant"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Échec de l'envoi"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "En sourdine"; diff --git a/MC1/Resources/Localization/it.lproj/Chats.strings b/MC1/Resources/Localization/it.lproj/Chats.strings index 4d3e482db..8ebd7ee4e 100644 --- a/MC1/Resources/Localization/it.lproj/Chats.strings +++ b/MC1/Resources/Localization/it.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Ancora nessun messaggio"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Invio non riuscito"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Silenziato"; diff --git a/MC1/Resources/Localization/nl.lproj/Chats.strings b/MC1/Resources/Localization/nl.lproj/Chats.strings index ca207e225..de8056ac4 100644 --- a/MC1/Resources/Localization/nl.lproj/Chats.strings +++ b/MC1/Resources/Localization/nl.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Nog geen berichten"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Verzenden mislukt"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Gedempt"; diff --git a/MC1/Resources/Localization/pl.lproj/Chats.strings b/MC1/Resources/Localization/pl.lproj/Chats.strings index db234f607..195c15bc5 100644 --- a/MC1/Resources/Localization/pl.lproj/Chats.strings +++ b/MC1/Resources/Localization/pl.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Brak wiadomości"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Nie udało się wysłać"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Wyciszony"; diff --git a/MC1/Resources/Localization/pt.lproj/Chats.strings b/MC1/Resources/Localization/pt.lproj/Chats.strings index 4292646f1..ccb8726b4 100644 --- a/MC1/Resources/Localization/pt.lproj/Chats.strings +++ b/MC1/Resources/Localization/pt.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Ainda sem mensagens"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Falha ao enviar"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Silenciado"; diff --git a/MC1/Resources/Localization/ru.lproj/Chats.strings b/MC1/Resources/Localization/ru.lproj/Chats.strings index 5f143cadb..c5ee65a5e 100644 --- a/MC1/Resources/Localization/ru.lproj/Chats.strings +++ b/MC1/Resources/Localization/ru.lproj/Chats.strings @@ -321,6 +321,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Пока нет сообщений"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Не удалось отправить"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Уведомления отключены"; diff --git a/MC1/Resources/Localization/uk.lproj/Chats.strings b/MC1/Resources/Localization/uk.lproj/Chats.strings index e1c5c4d97..890594cdc 100644 --- a/MC1/Resources/Localization/uk.lproj/Chats.strings +++ b/MC1/Resources/Localization/uk.lproj/Chats.strings @@ -321,6 +321,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "Поки що немає повідомлень"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "Не вдалося надіслати"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "Сповіщення вимкнено"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings index 1b7bf00aa..65a5bd022 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings @@ -322,6 +322,9 @@ /* Location: ConversationRow.swift, ChannelConversationRow.swift - Default text when no messages exist */ "chats.row.noMessages" = "暂无消息"; +/* Location: ConversationRow.swift, ChannelConversationRow.swift, RoomConversationRow.swift - Accessibility label for failed-send indicator */ +"chats.row.failedSend" = "发送失败"; + /* Location: MutedIndicator.swift - Accessibility label for muted indicator */ "chats.row.muted" = "已静音"; diff --git a/MC1/Views/Chats/ChannelConversationRow.swift b/MC1/Views/Chats/ChannelConversationRow.swift index 937a172f9..9edf5dc49 100644 --- a/MC1/Views/Chats/ChannelConversationRow.swift +++ b/MC1/Views/Chats/ChannelConversationRow.swift @@ -41,6 +41,10 @@ struct ChannelConversationRow: View { Spacer() + if viewModel.conversationHasFailedSend(channel.id) { + FailedSendIndicator() + } + UnreadBadges( unreadCount: channel.unreadCount, unreadMentionCount: channel.unreadMentionCount, diff --git a/MC1/Views/Chats/ChatsListModifiers.swift b/MC1/Views/Chats/ChatsListModifiers.swift index 8d3b89dfc..6f64e6732 100644 --- a/MC1/Views/Chats/ChatsListModifiers.swift +++ b/MC1/Views/Chats/ChatsListModifiers.swift @@ -72,6 +72,13 @@ struct ChatsListModifiers: ViewModifier { onHandlePendingChannelNavigation() onHandlePendingRoomNavigation() } + .task { + for await event in appState.messageEventStream.events() { + if viewModel.shouldRefreshFailedSendIndicators(for: event) { + await viewModel.refreshFailedSendIndicators() + } + } + } .onChange(of: appState.navigation.pendingChatContact) { _, _ in onHandlePendingNavigation() } diff --git a/MC1/Views/Chats/ConversationListContent.swift b/MC1/Views/Chats/ConversationListContent.swift index 66724dd57..407f36c75 100644 --- a/MC1/Views/Chats/ConversationListContent.swift +++ b/MC1/Views/Chats/ConversationListContent.swift @@ -228,7 +228,7 @@ private struct ConversationRowLabel: View { case let .channel(channel): ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: referenceDate) case let .room(session): - RoomConversationRow(session: session, referenceDate: referenceDate) + RoomConversationRow(session: session, viewModel: viewModel, referenceDate: referenceDate) } } .padding(.horizontal, ConversationRowLayout.horizontalPadding) diff --git a/MC1/Views/Chats/ConversationRow.swift b/MC1/Views/Chats/ConversationRow.swift index 028e51324..76b520519 100644 --- a/MC1/Views/Chats/ConversationRow.swift +++ b/MC1/Views/Chats/ConversationRow.swift @@ -43,6 +43,10 @@ struct ConversationRow: View { Spacer() + if viewModel.conversationHasFailedSend(contact.id) { + FailedSendIndicator() + } + UnreadBadges( unreadCount: contact.unreadCount, unreadMentionCount: contact.unreadMentionCount, diff --git a/MC1/Views/Chats/FailedSendIndicator.swift b/MC1/Views/Chats/FailedSendIndicator.swift new file mode 100644 index 000000000..ea8363901 --- /dev/null +++ b/MC1/Views/Chats/FailedSendIndicator.swift @@ -0,0 +1,187 @@ +import MC1Services +import SwiftUI + +/// Presence-only badge for a conversation with at least one outgoing failed send. +/// Solid fill — not Liquid Glass — so it stays readable under outdoor glare. +struct FailedSendIndicator: View { + private enum Metrics { + static let size: CGFloat = 18 + static let systemImage = "arrow.clockwise" + } + + var body: some View { + Image(systemName: Metrics.systemImage) + .font(.caption.bold()) + .foregroundStyle(.white) + .frame(width: Metrics.size, height: Metrics.size) + .background(Color.red, in: .circle) + .accessibilityLabel(L10n.Chats.Chats.Row.failedSend) + } +} + +#Preview("Badge") { + FailedSendIndicator() + .padding() +} + +#Preview("Conversation list") { + FailedSendIndicatorPreviewList() +} + +/// Fixture list for canvas / simulator preview of the failed-send row badge. +private struct FailedSendIndicatorPreviewList: View { + @State private var viewModel: ChatViewModel + private let alice: ContactDTO + private let bob: ContactDTO + private let channel: ChannelDTO + private let room: RemoteNodeSessionDTO + private let now: Date + + init() { + let now = Date() + let radioID = UUID() + let alice = ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0x11, count: 32), + name: "Alice Chen", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 2, + outPath: Data([0x10, 0x20]), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: now.addingTimeInterval(-120), + unreadCount: 2 + ) + let bob = ContactDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0x22, count: 32), + name: "Bob Martinez", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 1, + outPath: Data([0x20]), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: true, + lastMessageDate: now.addingTimeInterval(-900), + unreadCount: 0 + ) + let channel = ChannelDTO( + id: UUID(), + radioID: radioID, + index: 0, + name: "Mesh HQ", + secret: Data(), + isEnabled: true, + lastMessageDate: now.addingTimeInterval(-60), + unreadCount: 4, + unreadMentionCount: 1, + notificationLevel: .all, + isFavorite: false + ) + let room = RemoteNodeSessionDTO( + id: UUID(), + radioID: radioID, + publicKey: Data(repeating: 0x33, count: 32), + name: "Summit Room", + role: .roomServer, + isConnected: false, + unreadCount: 1, + lastMessageDate: now.addingTimeInterval(-3600) + ) + let viewModel = ChatViewModel() + viewModel.failedSendConversationIDs = [alice.id, channel.id, room.id] + viewModel.lastMessageCache = [ + alice.id: Self.previewMessage( + radioID: radioID, + contactID: alice.id, + text: "Need extra batteries at the aid station", + status: .failed, + now: now + ), + bob.id: Self.previewMessage( + radioID: radioID, + contactID: bob.id, + text: "Copy, staging at the trailhead", + status: .delivered, + now: now + ), + channel.id: Self.previewMessage( + radioID: radioID, + channelIndex: channel.index, + text: "Net check — anyone copy?", + status: .failed, + now: now + ) + ] + self.now = now + self.alice = alice + self.bob = bob + self.channel = channel + self.room = room + _viewModel = State(initialValue: viewModel) + } + + var body: some View { + NavigationStack { + List { + ConversationRow(contact: alice, viewModel: viewModel, referenceDate: now) + ConversationRow(contact: bob, viewModel: viewModel, referenceDate: now) + ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: now) + RoomConversationRow(session: room, viewModel: viewModel, referenceDate: now) + } + .listStyle(.plain) + .navigationTitle(L10n.Chats.Chats.title) + } + .environment(\.appState, AppState()) + } + + private static func previewMessage( + radioID: UUID, + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + text: String, + status: MessageStatus, + now: Date + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: channelIndex, + text: text, + timestamp: UInt32(now.timeIntervalSince1970), + createdAt: now, + direction: .outgoing, + status: status, + textType: .plain, + ackCode: nil, + pathLength: 1, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: true, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: status == .failed ? 3 : 0, + maxRetryAttempts: 3 + ) + } +} diff --git a/MC1/Views/Chats/Room/RoomConversationRow.swift b/MC1/Views/Chats/Room/RoomConversationRow.swift index a5618449b..ecffa1bf6 100644 --- a/MC1/Views/Chats/Room/RoomConversationRow.swift +++ b/MC1/Views/Chats/Room/RoomConversationRow.swift @@ -4,6 +4,7 @@ import SwiftUI struct RoomConversationRow: View { @Environment(\.appState) private var appState let session: RemoteNodeSessionDTO + let viewModel: ChatViewModel var referenceDate: Date? var body: some View { @@ -45,6 +46,10 @@ struct RoomConversationRow: View { Spacer() + if viewModel.conversationHasFailedSend(session.id) { + FailedSendIndicator() + } + UnreadBadges( unreadCount: session.unreadCount, notificationLevel: session.notificationLevel diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift index eff6443fa..b5f12b478 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Channels.swift @@ -30,6 +30,7 @@ extension ChatViewModel { do { try await dataStore?.clearChannelUnreadCount(channelID: channel.id) try await dataStore?.clearChannelUnreadMentionCount(channelID: channel.id) + try await dataStore?.markFailedSendsSeen(radioID: channel.radioID, channelIndex: channel.index) } catch { logger.warning("loadChannelMessages: failed to clear unread counts - \(error.localizedDescription)") } @@ -170,10 +171,7 @@ extension ChatViewModel { 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) - timeline.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) + await recordLocalEnqueueFailure(messageID: message.id, error: error) } } @@ -233,10 +231,7 @@ extension ChatViewModel { 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) - timeline.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) + await recordLocalEnqueueFailure(messageID: message.id, error: error) } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift index 633fec79a..7146bdd3c 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+ConversationList.swift @@ -182,6 +182,9 @@ extension ChatViewModel { channelSenderOrder = [:] contactNameSet = [] lastMessageCache = [:] + // Invalidates in-flight indicator refreshes; the list event task is not `reloadTask`. + failedSendRefreshGeneration &+= 1 + failedSendConversationIDs = [] recomputeSnapshot() } @@ -320,6 +323,8 @@ extension ChatViewModel { // Skip the trailing preview load if this reload was superseded. if Task.isCancelled { return } await loadLastMessagePreviews() + if Task.isCancelled { return } + await refreshFailedSendIndicators() } // MARK: - Message Previews @@ -391,4 +396,61 @@ extension ChatViewModel { } } } + + // MARK: - Failed-send indicators + + /// True when the conversation row for `id` should show the failed-send badge. + func conversationHasFailedSend(_ id: UUID) -> Bool { + failedSendConversationIDs.contains(id) + } + + /// Replaces `failedSendConversationIDs` with the union of the store's list ids. + func applyFailedSendKeys(_ keys: FailedSendConversationKeys) { + failedSendConversationIDs = keys.contactIDs + .union(keys.channelIDs) + .union(keys.roomSessionIDs) + } + + /// Re-runs the failed-send query and applies the keys. Does not reload + /// contacts, channels, or rooms. + func refreshFailedSendIndicators() async { + failedSendRefreshGeneration &+= 1 + let generation = failedSendRefreshGeneration + guard let dataStore, let radioID = currentRadioIDProvider() else { + failedSendConversationIDs = [] + return + } + do { + #if DEBUG + try failedSendRefreshFaultInjection?() + #endif + let keys = try await dataStore.fetchFailedSendConversationKeys(radioID: radioID) + #if DEBUG + await failedSendRefreshInterleaveHook?() + #endif + guard generation == failedSendRefreshGeneration, !Task.isCancelled else { return } + applyFailedSendKeys(keys) + } catch is CancellationError { + return + } catch { + guard generation == failedSendRefreshGeneration, !Task.isCancelled else { return } + logger.warning("Failed to load failed-send indicators: \(error)") + } + } + + /// Whether a list `MessageEvent` should trigger `refreshFailedSendIndicators()`. + /// Status-resolved ACKs (DM and room) only requery when a badge is already + /// showing, so a retry success can drop it without hitting the store on every ACK. + func shouldRefreshFailedSendIndicators(for event: MessageEvent) -> Bool { + switch event { + case .messageFailed, .messageResent, .roomMessageFailed: + true + case .messageStatusResolved, .roomMessageStatusUpdated: + !failedSendConversationIDs.isEmpty + case .directMessageReceived, .channelMessageReceived, .roomMessageReceived, + .messageRetrying, .heardRepeatRecorded, .reactionReceived, + .messagesRegionUpdated, .routingChanged: + false + } + } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift index d67a801a8..46db94afd 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+EventStream.swift @@ -45,10 +45,13 @@ extension ChatViewModel { // not coalescer-eligible because attempt/maxAttempts are per-event. timeline.enqueueReload(messageID: messageID) - case let .messageResent(messageID), - let .messageFailed(messageID): + case let .messageResent(messageID): timeline.enqueueReload(messageID: messageID) + case let .messageFailed(messageID): + timeline.enqueueReload(messageID: messageID) + await markFailedSendSeenIfCurrent(messageID: messageID) + case let .heardRepeatRecorded(messageID, _), let .reactionReceived(messageID, _): timeline.enqueueReload(messageID: messageID) @@ -74,6 +77,41 @@ extension ChatViewModel { contactRefreshSignal &+= 1 } + /// Local enqueue/retry threw before the send queue could emit `.messageFailed`. + /// Writes `.failed` (which resets `failureSeen`) then marks the open thread + /// seen so the list badge does not reappear on pop-back. + func recordLocalEnqueueFailure(messageID: UUID, error: Error) async { + logger.error("enqueue failed for messageID=\(messageID, privacy: .public): \(String(describing: error))") + _ = try? await dataStore?.updateMessageStatusUnlessDelivered(id: messageID, status: .failed) + timeline.applyStatusUpdate(messageID: messageID, status: .failed) + sendErrorMessage = Self.copyForEnqueueFailure(error) + await markFailedSendSeenIfCurrent(messageID: messageID) + } + + /// A fail that lands while this thread is open is already on screen; mark it + /// seen so the list badge does not reappear on pop-back. + private func markFailedSendSeenIfCurrent(messageID: UUID) async { + guard let dataStore, let message = try? await dataStore.fetchMessage(id: messageID) else { + return + } + do { + if let contact = currentContact, message.contactID == contact.id { + try await dataStore.markFailedSendsSeen(contactID: contact.id) + syncCoordinator?.notifyConversationsChanged() + } else if let channel = currentChannel, + message.channelIndex == channel.index, + message.radioID == channel.radioID { + try await dataStore.markFailedSendsSeen( + radioID: channel.radioID, + channelIndex: channel.index + ) + syncCoordinator?.notifyConversationsChanged() + } + } catch { + logger.warning("Failed to mark in-thread failed send seen: \(error.localizedDescription)") + } + } + private func recordIncomingMentionIfNeeded(_ message: MessageDTO) { guard message.containsSelfMention else { return } mentionSequence &+= 1 diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift index b43c6644f..9d0da471c 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+MessageActions.swift @@ -72,10 +72,7 @@ extension ChatViewModel { 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) - timeline.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) + await recordLocalEnqueueFailure(messageID: message.id, error: error) } } else { // Identity-preserving DM resend. Mirrors retryMessage: route through diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift index 3577c79e2..fa228d01a 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Messages.swift @@ -75,6 +75,7 @@ extension ChatViewModel { do { try await dataStore?.clearUnreadCount(contactID: contact.id) try await dataStore?.clearUnreadMentionCount(contactID: contact.id) + try await dataStore?.markFailedSendsSeen(contactID: contact.id) } catch { logger.warning("loadMessages: failed to clear unread counts - \(error.localizedDescription)") } @@ -205,10 +206,7 @@ extension ChatViewModel { 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) - timeline.applyStatusUpdate(messageID: message.id, status: .failed) - sendErrorMessage = Self.copyForEnqueueFailure(error) + await recordLocalEnqueueFailure(messageID: message.id, error: error) } } } diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel.swift b/MC1/Views/Chats/ViewModel/ChatViewModel.swift index 7c6fa6f56..59d9b5f5d 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel.swift @@ -91,6 +91,14 @@ final class ChatViewModel { /// 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)? + + /// Test-only interleave hook, awaited after the failed-send keys fetch so a + /// test can suspend refresh #1 until a newer refresh has applied. + @ObservationIgnored var failedSendRefreshInterleaveHook: (@MainActor () async -> Void)? + + /// Test-only fault injection fired immediately before the failed-send keys + /// fetch so tests can exercise the keep-last-known catch path. + @ObservationIgnored var failedSendRefreshFaultInjection: (@MainActor () throws -> Void)? #endif // MARK: - Conversation Cache Storage @@ -222,6 +230,14 @@ final class ChatViewModel { /// Last message previews cache var lastMessageCache: [UUID: MessageDTO] = [:] + /// Conversation ids (contact, channel, or room session) with at least one + /// unseen outgoing `.failed` send. Observed so list rows invalidate like previews. + var failedSendConversationIDs: Set = [] + + /// Bumped at the start of each failed-send indicator refresh so a slower + /// first fetch cannot publish after a newer overlapping refresh. + @ObservationIgnored var failedSendRefreshGeneration = 0 + /// 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 diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift index 4400db245..60416b6dd 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift @@ -1,10 +1,13 @@ import MC1Services +import OSLog import SwiftUI /// ViewModel for room conversation operations @Observable @MainActor final class RoomConversationViewModel { + private let logger = Logger(subsystem: "com.mc1", category: "RoomConversationViewModel") + // MARK: - Properties /// Current room session @@ -99,6 +102,7 @@ final class RoomConversationViewModel { // 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) + try await dataStore?.markFailedSendsSeen(roomSessionID: session.id) await notificationService?.removeDeliveredNotifications(forRoomSessionID: session.id) await notificationService?.updateBadgeCount() syncCoordinator?.notifyConversationsChanged() @@ -187,6 +191,12 @@ final class RoomConversationViewModel { case let .roomMessageFailed(messageID): if messages.contains(where: { $0.id == messageID }) { scheduleCoalescedReload() + do { + try await dataStore?.markFailedSendsSeen(roomSessionID: session.id) + syncCoordinator?.notifyConversationsChanged() + } catch { + logger.warning("Failed to mark in-thread room failed send seen: \(error.localizedDescription)") + } } case .directMessageReceived, .channelMessageReceived, diff --git a/MC1Services/Sources/MC1Services/Models/FailedSendConversationKeys.swift b/MC1Services/Sources/MC1Services/Models/FailedSendConversationKeys.swift new file mode 100644 index 000000000..767d5e51f --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/FailedSendConversationKeys.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Conversation identities with at least one unseen outgoing `.failed` send. +/// Channel IDs are resolved under the query `radioID` so list mapping cannot attach another radio's slot. +public struct FailedSendConversationKeys: Sendable, Equatable { + public let contactIDs: Set + public let channelIDs: Set + public let roomSessionIDs: Set + + public init( + contactIDs: Set = [], + channelIDs: Set = [], + roomSessionIDs: Set = [] + ) { + self.contactIDs = contactIDs + self.channelIDs = channelIDs + self.roomSessionIDs = roomSessionIDs + } + + public static let empty = FailedSendConversationKeys() +} diff --git a/MC1Services/Sources/MC1Services/Models/Message.swift b/MC1Services/Sources/MC1Services/Models/Message.swift index f92bcac49..b4f1ca73b 100644 --- a/MC1Services/Sources/MC1Services/Models/Message.swift +++ b/MC1Services/Sources/MC1Services/Models/Message.swift @@ -134,6 +134,10 @@ public final class Message { /// Whether the user has scrolled to see this mention (for tracking unread mentions) public var mentionSeen: Bool = false + /// Whether the user has opened the conversation after this outgoing send failed. + /// The conversation-list badge shows only unseen `.failed` rows. + public var failureSeen: Bool = false + /// Whether the timestamp was corrected due to sender clock being invalid public var timestampCorrected: Bool = false @@ -195,6 +199,7 @@ public final class Message { linkPreviewFetched: Bool = false, containsSelfMention: Bool = false, mentionSeen: Bool = false, + failureSeen: Bool = false, timestampCorrected: Bool = false, senderTimestamp: UInt32? = nil, reactionSummary: String? = nil, @@ -234,6 +239,7 @@ public final class Message { self.linkPreviewFetched = linkPreviewFetched self.containsSelfMention = containsSelfMention self.mentionSeen = mentionSeen + self.failureSeen = failureSeen self.timestampCorrected = timestampCorrected self.senderTimestamp = senderTimestamp self.reactionSummary = reactionSummary @@ -280,6 +286,7 @@ public final class Message { linkPreviewFetched: false, containsSelfMention: dto.containsSelfMention, mentionSeen: dto.mentionSeen, + failureSeen: dto.failureSeen, timestampCorrected: dto.timestampCorrected, senderTimestamp: dto.senderTimestamp, reactionSummary: dto.reactionSummary, @@ -366,6 +373,7 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { public var linkPreviewFetched: Bool public var containsSelfMention: Bool public var mentionSeen: Bool + public var failureSeen: Bool public var timestampCorrected: Bool public var senderTimestamp: UInt32? public var reactionSummary: String? @@ -385,8 +393,8 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { roundTripTime, heardRepeats, sendCount, retryAttempt, maxRetryAttempts, deduplicationKey, linkPreviewURL, linkPreviewTitle, linkPreviewImageData, linkPreviewIconData, linkPreviewFetched, containsSelfMention, mentionSeen, - timestampCorrected, senderTimestamp, reactionSummary, routeType, regionScope, - regionScopeMatches + failureSeen, timestampCorrected, senderTimestamp, reactionSummary, routeType, + regionScope, regionScopeMatches } public init(from decoder: Decoder) throws { @@ -424,6 +432,7 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { linkPreviewFetched = try container.decode(Bool.self, forKey: .linkPreviewFetched) containsSelfMention = try container.decode(Bool.self, forKey: .containsSelfMention) mentionSeen = try container.decode(Bool.self, forKey: .mentionSeen) + failureSeen = try container.decodeIfPresent(Bool.self, forKey: .failureSeen) ?? false timestampCorrected = try container.decode(Bool.self, forKey: .timestampCorrected) senderTimestamp = try container.decodeIfPresent(UInt32.self, forKey: .senderTimestamp) reactionSummary = try container.decodeIfPresent(String.self, forKey: .reactionSummary) @@ -475,6 +484,7 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { } containsSelfMention = message.containsSelfMention mentionSeen = message.mentionSeen + failureSeen = message.failureSeen timestampCorrected = message.timestampCorrected senderTimestamp = message.senderTimestamp reactionSummary = message.reactionSummary @@ -518,6 +528,7 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { linkPreviewFetched: Bool = false, containsSelfMention: Bool = false, mentionSeen: Bool = false, + failureSeen: Bool = false, timestampCorrected: Bool = false, senderTimestamp: UInt32? = nil, reactionSummary: String? = nil, @@ -557,6 +568,7 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable, Codable { self.linkPreviewFetched = linkPreviewFetched self.containsSelfMention = containsSelfMention self.mentionSeen = mentionSeen + self.failureSeen = failureSeen self.timestampCorrected = timestampCorrected self.senderTimestamp = senderTimestamp self.reactionSummary = reactionSummary diff --git a/MC1Services/Sources/MC1Services/Models/RoomMessage.swift b/MC1Services/Sources/MC1Services/Models/RoomMessage.swift index a385a11a4..4b4eb7bd3 100644 --- a/MC1Services/Sources/MC1Services/Models/RoomMessage.swift +++ b/MC1Services/Sources/MC1Services/Models/RoomMessage.swift @@ -54,6 +54,10 @@ public final class RoomMessage { /// Maximum retry attempts configured public var maxRetryAttempts: Int = 0 + /// Whether the user has opened the room after this outgoing send failed. + /// The conversation-list badge shows only unseen `.failed` rows. + public var failureSeen: Bool = false + public init( id: UUID = UUID(), sessionID: UUID, @@ -99,6 +103,7 @@ public final class RoomMessage { roundTripTime = dto.roundTripTime retryAttempt = dto.retryAttempt maxRetryAttempts = dto.maxRetryAttempts + failureSeen = dto.failureSeen } } @@ -156,6 +161,7 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab public let roundTripTime: UInt32? public let retryAttempt: Int public let maxRetryAttempts: Int + public let failureSeen: Bool public init(from model: RoomMessage) { id = model.id @@ -172,6 +178,7 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab roundTripTime = model.roundTripTime retryAttempt = model.retryAttempt maxRetryAttempts = model.maxRetryAttempts + failureSeen = model.failureSeen } public init( @@ -187,7 +194,8 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab ackCode: UInt32? = nil, roundTripTime: UInt32? = nil, retryAttempt: Int = 0, - maxRetryAttempts: Int = 0 + maxRetryAttempts: Int = 0, + failureSeen: Bool = false ) { self.id = id self.sessionID = sessionID @@ -202,6 +210,7 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab self.roundTripTime = roundTripTime self.retryAttempt = retryAttempt self.maxRetryAttempts = maxRetryAttempts + self.failureSeen = failureSeen deduplicationKey = RoomMessage.generateDeduplicationKey( timestamp: timestamp, authorKeyPrefix: authorKeyPrefix, @@ -209,6 +218,31 @@ public struct RoomMessageDTO: Sendable, Equatable, Identifiable, Hashable, Codab ) } + private enum CodingKeys: String, CodingKey { + case id, sessionID, authorKeyPrefix, authorName, text, timestamp, createdAt, + isFromSelf, deduplicationKey, statusRawValue, ackCode, roundTripTime, + retryAttempt, maxRetryAttempts, failureSeen + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + sessionID = try container.decode(UUID.self, forKey: .sessionID) + authorKeyPrefix = try container.decode(Data.self, forKey: .authorKeyPrefix) + authorName = try container.decodeIfPresent(String.self, forKey: .authorName) + text = try container.decode(String.self, forKey: .text) + timestamp = try container.decode(UInt32.self, forKey: .timestamp) + createdAt = try container.decode(Date.self, forKey: .createdAt) + isFromSelf = try container.decode(Bool.self, forKey: .isFromSelf) + deduplicationKey = try container.decode(String.self, forKey: .deduplicationKey) + statusRawValue = try container.decode(Int.self, forKey: .statusRawValue) + ackCode = try container.decodeIfPresent(UInt32.self, forKey: .ackCode) + roundTripTime = try container.decodeIfPresent(UInt32.self, forKey: .roundTripTime) + retryAttempt = try container.decode(Int.self, forKey: .retryAttempt) + maxRetryAttempts = try container.decode(Int.self, forKey: .maxRetryAttempts) + failureSeen = try container.decodeIfPresent(Bool.self, forKey: .failureSeen) ?? false + } + public var authorDisplayName: String { authorName ?? authorKeyPrefix.map { String(format: "%02X", $0) }.joined() } diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+FailedSends.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+FailedSends.swift new file mode 100644 index 000000000..65ecbac35 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+FailedSends.swift @@ -0,0 +1,131 @@ +import Foundation +import SwiftData + +public extension PersistenceStore { + /// Collects conversation identities that have at least one unseen outgoing + /// `.failed` send for `radioID`. Channel IDs are resolved under this radio. + /// Rooms are fetched by this radio's session IDs. + func fetchFailedSendConversationKeys(radioID: UUID) throws -> FailedSendConversationKeys { + let targetRadioID = radioID + let failedStatus = MessageStatus.failed.rawValue + let outgoing = MessageDirection.outgoing.rawValue + + let messagePredicate = #Predicate { message in + message.radioID == targetRadioID + && message.statusRawValue == failedStatus + && message.directionRawValue == outgoing + && message.failureSeen == false + } + let failedMessages = try modelContext.fetch(FetchDescriptor(predicate: messagePredicate)) + + var contactIDs: Set = [] + var channelIndexes: Set = [] + contactIDs.reserveCapacity(failedMessages.count) + channelIndexes.reserveCapacity(failedMessages.count) + for message in failedMessages { + if let contactID = message.contactID { + contactIDs.insert(contactID) + } + if let channelIndex = message.channelIndex { + channelIndexes.insert(channelIndex) + } + } + + var channelIDs: Set = [] + if !channelIndexes.isEmpty { + let channels = try fetchChannels(radioID: targetRadioID) + for channel in channels where channelIndexes.contains(channel.index) { + channelIDs.insert(channel.id) + } + } + + let sessionPredicate = #Predicate { session in + session.radioID == targetRadioID + } + let sessions = try modelContext.fetch(FetchDescriptor(predicate: sessionPredicate)) + let radioSessionIDs = Set(sessions.map(\.id)) + guard !radioSessionIDs.isEmpty else { + return FailedSendConversationKeys( + contactIDs: contactIDs, + channelIDs: channelIDs, + roomSessionIDs: [] + ) + } + + let sessionIDs = Array(radioSessionIDs) + let failedRoomMessages = try fetchInChunks(keys: sessionIDs) { chunk in + let sessionChunk = chunk + let predicate = #Predicate { message in + sessionChunk.contains(message.sessionID) + && message.statusRawValue == failedStatus + && message.isFromSelf + && message.failureSeen == false + } + return try modelContext.fetch(FetchDescriptor(predicate: predicate)) + } + let roomSessionIDs = Set(failedRoomMessages.map(\.sessionID)) + + return FailedSendConversationKeys( + contactIDs: contactIDs, + channelIDs: channelIDs, + roomSessionIDs: roomSessionIDs + ) + } + + /// Marks current outgoing `.failed` DMs in `contactID` as seen by the user. + func markFailedSendsSeen(contactID: UUID) throws { + let targetContactID: UUID? = contactID + let failedStatus = MessageStatus.failed.rawValue + let outgoing = MessageDirection.outgoing.rawValue + let predicate = #Predicate { message in + message.contactID == targetContactID + && message.statusRawValue == failedStatus + && message.directionRawValue == outgoing + && message.failureSeen == false + } + try markMessagesFailureSeen(predicate) + } + + /// Marks current outgoing `.failed` channel rows as seen by the user. + func markFailedSendsSeen(radioID: UUID, channelIndex: UInt8) throws { + let targetRadioID = radioID + let targetIndex: UInt8? = channelIndex + let failedStatus = MessageStatus.failed.rawValue + let outgoing = MessageDirection.outgoing.rawValue + let predicate = #Predicate { message in + message.radioID == targetRadioID + && message.channelIndex == targetIndex + && message.statusRawValue == failedStatus + && message.directionRawValue == outgoing + && message.failureSeen == false + } + try markMessagesFailureSeen(predicate) + } + + /// Marks current self `.failed` room rows as seen by the user. + func markFailedSendsSeen(roomSessionID: UUID) throws { + let targetSessionID = roomSessionID + let failedStatus = MessageStatus.failed.rawValue + let predicate = #Predicate { message in + message.sessionID == targetSessionID + && message.statusRawValue == failedStatus + && message.isFromSelf + && message.failureSeen == false + } + let messages = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + guard !messages.isEmpty else { return } + for message in messages { + message.failureSeen = true + } + try modelContext.save() + } + + private func markMessagesFailureSeen(_ predicate: Predicate) throws { + let messages = try modelContext.fetch(FetchDescriptor(predicate: predicate)) + guard !messages.isEmpty else { return } + for message in messages { + message.failureSeen = true + } + try modelContext.save() + } +} diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift index 0c5f38771..9e2a6e848 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Messages.swift @@ -395,6 +395,9 @@ public extension PersistenceStore { descriptor.fetchLimit = 1 if let message = try modelContext.fetch(descriptor).first { + if status == .failed, message.status != .failed { + message.failureSeen = false + } message.status = status try modelContext.save() } @@ -418,6 +421,9 @@ public extension PersistenceStore { guard let message = try modelContext.fetch(descriptor).first, message.status != .delivered else { return false } + if status == .failed, message.status != .failed { + message.failureSeen = false + } message.status = status try modelContext.save() return true diff --git a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift index 58ca7c808..db31a752d 100644 --- a/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift +++ b/MC1Services/Sources/MC1Services/Services/PersistenceStore+Rooms.swift @@ -290,6 +290,9 @@ public extension PersistenceStore { guard let message = try modelContext.fetch(descriptor).first else { return } + if status == .failed, message.status != .failed { + message.failureSeen = false + } message.statusRawValue = status.rawValue if let ackCode { message.ackCode = ackCode @@ -316,6 +319,9 @@ public extension PersistenceStore { guard let message = try modelContext.fetch(descriptor).first else { return } + if status == .failed, message.status != .failed { + message.failureSeen = false + } message.statusRawValue = status.rawValue message.retryAttempt = retryAttempt message.maxRetryAttempts = maxRetryAttempts diff --git a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift index 16fe64b9e..a9ea810c7 100644 --- a/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/BackupIntegrationTests.swift @@ -3363,6 +3363,76 @@ struct BackupIntegrationTests { #expect(model.regionScopeMatches == ["de-by", "de-hh"]) } + // MARK: - failureSeen round-trip + + @Test + func `MessageDTO Codable: failureSeen true round-trips`() throws { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Failed") + dto.failureSeen = true + dto.status = .failed + + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: encoded) + #expect(decoded.failureSeen == true) + } + + @Test + func `Legacy MessageDTO envelope without failureSeen decodes as false`() 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: "failureSeen") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(MessageDTO.self, from: stripped) + #expect(decoded.failureSeen == false) + } + + @Test + func `Message(dto:) forwards failureSeen verbatim through DTO to model`() { + var dto = MessageDTO.testDirectMessage(radioID: UUID(), contactID: UUID(), text: "Forward") + dto.failureSeen = true + let model = Message(dto: dto) + #expect(model.failureSeen == true) + } + + @Test + func `RoomMessageDTO Codable: failureSeen true round-trips`() throws { + let dto = RoomMessageDTO.testRoomMessage( + sessionID: UUID(), + isFromSelf: true, + status: .failed, + failureSeen: true + ) + let encoded = try JSONEncoder().encode(dto) + let decoded = try JSONDecoder().decode(RoomMessageDTO.self, from: encoded) + #expect(decoded.failureSeen == true) + } + + @Test + func `Legacy RoomMessageDTO envelope without failureSeen decodes as false`() throws { + let baseDTO = RoomMessageDTO.testRoomMessage(sessionID: UUID(), isFromSelf: true, status: .failed) + let encoded = try JSONEncoder().encode(baseDTO) + var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + json.removeValue(forKey: "failureSeen") + + let stripped = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(RoomMessageDTO.self, from: stripped) + #expect(decoded.failureSeen == false) + } + + @Test + func `RoomMessage(dto:) forwards failureSeen verbatim through DTO to model`() { + let dto = RoomMessageDTO.testRoomMessage( + sessionID: UUID(), + isFromSelf: true, + status: .failed, + failureSeen: true + ) + let model = RoomMessage(dto: dto) + #expect(model.failureSeen == true) + } + // MARK: - MessageDTO.sortDate round-trip @Test diff --git a/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift b/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift new file mode 100644 index 000000000..73e1b2b76 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift @@ -0,0 +1,328 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("Failed-send conversation keys") +struct FailedSendConversationKeysTests { + private func createTestStore() async throws -> PersistenceStore { + let container = try PersistenceStore.createContainer(inMemory: true) + return PersistenceStore(modelContainer: container) + } + + @Test + func `outgoing failed DM includes the contact id`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: contactID, status: .failed) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [contactID]) + #expect(keys.channelIDs.isEmpty) + #expect(keys.roomSessionIDs.isEmpty) + } + + @Test + func `incoming sent retrying pending sending and other radio DMs are absent`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let otherRadioID = UUID() + let incomingID = UUID() + let sentID = UUID() + let retryingID = UUID() + let pendingID = UUID() + let sendingID = UUID() + let otherRadioContactID = UUID() + + try await store.saveMessage( + .testDirectMessage( + radioID: radioID, + contactID: incomingID, + direction: .incoming, + status: .failed + ) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: sentID, status: .sent) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: retryingID, status: .retrying) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: pendingID, status: .pending) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: sendingID, status: .sending) + ) + try await store.saveMessage( + .testDirectMessage( + radioID: otherRadioID, + contactID: otherRadioContactID, + status: .failed + ) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs.isEmpty) + #expect(keys.channelIDs.isEmpty) + #expect(keys.roomSessionIDs.isEmpty) + } + + @Test + func `outgoing failed channel message includes that channel id`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let channel = ChannelDTO.testChannel(radioID: radioID, index: 3) + try await store.saveChannel(channel) + try await store.saveMessage( + .testChannelMessage(radioID: radioID, channelIndex: channel.index, status: .failed) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.channelIDs == [channel.id]) + #expect(keys.contactIDs.isEmpty) + #expect(keys.roomSessionIDs.isEmpty) + } + + @Test + func `failed channel on another radio with the same index is absent`() async throws { + let store = try await createTestStore() + let radioA = UUID() + let radioB = UUID() + let channelA = ChannelDTO.testChannel(radioID: radioA, index: 0) + let channelB = ChannelDTO.testChannel(radioID: radioB, index: 0) + try await store.saveChannel(channelA) + try await store.saveChannel(channelB) + try await store.saveMessage( + .testChannelMessage(radioID: radioA, channelIndex: 0, status: .failed) + ) + + let keysA = try await store.fetchFailedSendConversationKeys(radioID: radioA) + let keysB = try await store.fetchFailedSendConversationKeys(radioID: radioB) + + #expect(keysA.channelIDs == [channelA.id]) + #expect(!keysA.channelIDs.contains(channelB.id)) + #expect(keysB.channelIDs.isEmpty) + } + + @Test + func `failed channel message with no Channel row yields no channel id`() async throws { + let store = try await createTestStore() + let radioID = UUID() + try await store.saveMessage( + .testChannelMessage(radioID: radioID, channelIndex: 3, status: .failed) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.channelIDs.isEmpty) + } + + @Test + func `failed self room message includes that session and excludes other radios`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let otherRadioID = UUID() + let session = RemoteNodeSessionDTO.testSession(radioID: radioID) + let otherSession = RemoteNodeSessionDTO.testSession( + radioID: otherRadioID, + publicKey: Data(repeating: 0xDD, count: 32) + ) + try await store.saveRemoteNodeSessionDTO(session) + try await store.saveRemoteNodeSessionDTO(otherSession) + + try await store.saveRoomMessage( + .testRoomMessage( + sessionID: session.id, + text: "failed self send", + isFromSelf: true, + status: .failed + ) + ) + try await store.saveRoomMessage( + .testRoomMessage( + sessionID: otherSession.id, + text: "other radio failed self send", + isFromSelf: true, + status: .failed + ) + ) + try await store.saveRoomMessage( + .testRoomMessage( + sessionID: session.id, + text: "incoming failed", + isFromSelf: false, + status: .failed + ) + ) + try await store.saveRoomMessage( + .testRoomMessage( + sessionID: session.id, + text: "self sent", + isFromSelf: true, + status: .sent + ) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + let otherKeys = try await store.fetchFailedSendConversationKeys(radioID: otherRadioID) + + #expect(keys.roomSessionIDs == [session.id]) + #expect(!keys.roomSessionIDs.contains(otherSession.id)) + #expect(otherKeys.roomSessionIDs == [otherSession.id]) + #expect(!otherKeys.roomSessionIDs.contains(session.id)) + } + + @Test + func `outgoing failed reaction is included`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + try await store.saveMessage( + .testDirectMessage( + radioID: radioID, + contactID: contactID, + text: "👍", + status: .failed + ) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [contactID]) + } + + @Test + func `multiple failed DMs for one contact collapse to one id`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: contactID, text: "one", status: .failed) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: contactID, text: "two", status: .failed) + ) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [contactID]) + } + + @Test + func `empty store returns empty keys`() async throws { + let store = try await createTestStore() + + let keys = try await store.fetchFailedSendConversationKeys(radioID: UUID()) + + #expect(keys == .empty) + } + + @Test + func `seen outgoing failed DM is absent`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + var message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + status: .failed + ) + message.failureSeen = true + try await store.saveMessage(message) + + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs.isEmpty) + } + + @Test + func `markFailedSendsSeen drops only that contact`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let seenID = UUID() + let otherID = UUID() + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: seenID, status: .failed) + ) + try await store.saveMessage( + .testDirectMessage(radioID: radioID, contactID: otherID, status: .failed) + ) + + try await store.markFailedSendsSeen(contactID: seenID) + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [otherID]) + } + + @Test + func `markFailedSendsSeen drops a room session`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let sessionID = UUID() + try await store.saveRemoteNodeSessionDTO( + RemoteNodeSessionDTO( + id: sessionID, + radioID: radioID, + publicKey: Data(repeating: 0x11, count: 32), + name: "Room", + role: .roomServer + ) + ) + try await store.saveRoomMessage( + .testRoomMessage( + sessionID: sessionID, + isFromSelf: true, + status: .failed + ) + ) + + try await store.markFailedSendsSeen(roomSessionID: sessionID) + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.roomSessionIDs.isEmpty) + } + + @Test + func `transition to failed from sent clears failureSeen`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + var message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + status: .sent + ) + message.failureSeen = true + try await store.saveMessage(message) + + try await store.updateMessageStatus(id: message.id, status: .failed) + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs == [contactID]) + } + + @Test + func `idempotent failed write does not clear failureSeen`() async throws { + let store = try await createTestStore() + let radioID = UUID() + let contactID = UUID() + let message = MessageDTO.testDirectMessage( + radioID: radioID, + contactID: contactID, + status: .failed + ) + try await store.saveMessage(message) + try await store.markFailedSendsSeen(contactID: contactID) + + try await store.updateMessageStatus(id: message.id, status: .failed) + let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) + + #expect(keys.contactIDs.isEmpty) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift b/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift index 23cdf18e2..d6f0420d2 100644 --- a/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift +++ b/MC1Services/Tests/MC1ServicesTests/Helpers/RoomMessageDTO+Testing.swift @@ -17,7 +17,8 @@ extension RoomMessageDTO { timestamp: UInt32 = 1_700_000_000, createdAt: Date = Date(), isFromSelf: Bool = false, - status: MessageStatus = .delivered + status: MessageStatus = .delivered, + failureSeen: Bool = false ) -> RoomMessageDTO { RoomMessageDTO( id: id, @@ -28,7 +29,8 @@ extension RoomMessageDTO { timestamp: timestamp, createdAt: createdAt, isFromSelf: isFromSelf, - status: status + status: status, + failureSeen: failureSeen ) } } diff --git a/MC1Tests/Views/Chats/ChatViewModelFailedSendTests.swift b/MC1Tests/Views/Chats/ChatViewModelFailedSendTests.swift new file mode 100644 index 000000000..3c5a5123e --- /dev/null +++ b/MC1Tests/Views/Chats/ChatViewModelFailedSendTests.swift @@ -0,0 +1,643 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("ChatViewModel failed-send indicators") +@MainActor +struct ChatViewModelFailedSendTests { + private func makeViewModel() -> ChatViewModel { + ChatViewModel() + } + + private func makeContact( + id: UUID = UUID(), + radioID: UUID = UUID(), + lastMessageDate: Date? = Date() + ) -> ContactDTO { + ContactDTO( + id: id, + radioID: radioID, + publicKey: Data(repeating: UInt8(truncatingIfNeeded: id.hashValue), count: 32), + name: "Test", + typeRawValue: ContactType.chat.rawValue, + flags: 0, + outPathLength: 0, + outPath: Data(), + lastAdvertTimestamp: 0, + latitude: 0, + longitude: 0, + lastModified: 0, + lastHeardTimestamp: nil, + nickname: nil, + isBlocked: false, + isMuted: false, + isFavorite: false, + lastMessageDate: lastMessageDate, + unreadCount: 0 + ) + } + + private func makeChannel( + id: UUID = UUID(), + radioID: UUID = UUID(), + index: UInt8 = 0 + ) -> ChannelDTO { + ChannelDTO( + id: id, + radioID: radioID, + index: index, + name: "General", + secret: Data(), + isEnabled: true, + lastMessageDate: Date(), + unreadCount: 0, + unreadMentionCount: 0, + notificationLevel: .all, + isFavorite: false + ) + } + + private func makeRoom( + id: UUID = UUID(), + radioID: UUID = UUID() + ) -> RemoteNodeSessionDTO { + RemoteNodeSessionDTO( + id: id, + radioID: radioID, + publicKey: Data(repeating: UInt8(truncatingIfNeeded: id.hashValue), count: 32), + name: "Room", + role: .roomServer, + isConnected: true, + isFavorite: false, + lastMessageDate: Date() + ) + } + + private func makeMessage( + radioID: UUID, + contactID: UUID? = nil, + channelIndex: UInt8? = nil, + text: String = "hello", + status: MessageStatus = .failed + ) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: radioID, + contactID: contactID, + channelIndex: channelIndex, + text: text, + timestamp: 1_700_000_000, + createdAt: Date(), + direction: .outgoing, + status: status, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + @Test + func `applyFailedSendKeys maps contact id onto the list set`() { + let viewModel = makeViewModel() + let contact = makeContact() + viewModel.conversations = [contact] + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + + #expect(viewModel.failedSendConversationIDs == [contact.id]) + #expect(viewModel.conversationHasFailedSend(contact.id)) + #expect(!viewModel.conversationHasFailedSend(UUID())) + } + + @Test + func `applyFailedSendKeys unions channel ids`() { + let viewModel = makeViewModel() + let channel = makeChannel(index: 4) + let other = makeChannel(index: 7) + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(channelIDs: [channel.id])) + + #expect(viewModel.failedSendConversationIDs == [channel.id]) + #expect(viewModel.conversationHasFailedSend(channel.id)) + #expect(!viewModel.conversationHasFailedSend(other.id)) + } + + @Test + func `applyFailedSendKeys maps room session id`() { + let viewModel = makeViewModel() + let session = makeRoom() + viewModel.roomSessions = [session] + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(roomSessionIDs: [session.id])) + + #expect(viewModel.failedSendConversationIDs == [session.id]) + #expect(viewModel.conversationHasFailedSend(session.id)) + } + + @Test + func `clearConversations empties the failed-send set`() { + let viewModel = makeViewModel() + let contact = makeContact() + viewModel.conversations = [contact] + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + #expect(!viewModel.failedSendConversationIDs.isEmpty) + + viewModel.clearConversations() + + #expect(viewModel.failedSendConversationIDs.isEmpty) + #expect(!viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `refresh with empty keys clears a previously populated set`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + #expect(viewModel.conversationHasFailedSend(contact.id)) + + await viewModel.refreshFailedSendIndicators() + + #expect(viewModel.failedSendConversationIDs.isEmpty) + #expect(!viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `refresh loads failed DM channel and room keys from the store`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let channel = makeChannel(radioID: radioID, index: 2) + let session = makeRoom(radioID: radioID) + try await store.saveChannel(channel) + try await store.saveRemoteNodeSessionDTO(session) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + try await store.saveMessage( + makeMessage(radioID: radioID, channelIndex: channel.index, status: .failed) + ) + try await store.saveRoomMessage( + RoomMessageDTO( + sessionID: session.id, + authorKeyPrefix: Data([0xAB, 0xCD, 0xEF, 0x01]), + text: "failed room send", + timestamp: 1_700_000_000, + isFromSelf: true, + status: .failed + ) + ) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.channels = [channel] + viewModel.roomSessions = [session] + + await viewModel.refreshFailedSendIndicators() + + #expect(viewModel.conversationHasFailedSend(contact.id)) + #expect(viewModel.conversationHasFailedSend(channel.id)) + #expect(viewModel.conversationHasFailedSend(session.id)) + } + + @Test + func `conversation reload loads failed-send indicators`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID, lastMessageDate: Date()) + try await store.saveContact(contact) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + + await viewModel.requestConversationReload()?.value + + #expect(viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `refresh with no radio id clears the set`() async { + let viewModel = makeViewModel() + let contact = makeContact() + viewModel.conversations = [contact] + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { nil }, + currentRadioID: { nil } + ) + ) + await viewModel.refreshFailedSendIndicators() + + #expect(viewModel.failedSendConversationIDs.isEmpty) + } + + @Test + func `messageFailed and peers always refresh`() { + let viewModel = makeViewModel() + let messageID = UUID() + + #expect(viewModel.shouldRefreshFailedSendIndicators(for: .messageFailed(messageID: messageID))) + #expect(viewModel.shouldRefreshFailedSendIndicators(for: .messageResent(messageID: messageID))) + #expect(viewModel.shouldRefreshFailedSendIndicators(for: .roomMessageFailed(messageID: messageID))) + } + + @Test + func `messageStatusResolved refreshes only when a badge is showing`() { + let viewModel = makeViewModel() + let event = MessageEvent.messageStatusResolved(messageID: UUID(), status: .delivered) + + #expect(!viewModel.shouldRefreshFailedSendIndicators(for: event)) + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [UUID()])) + + #expect(viewModel.shouldRefreshFailedSendIndicators(for: event)) + } + + @Test + func `roomMessageStatusUpdated refreshes only when a badge is showing`() { + let viewModel = makeViewModel() + let event = MessageEvent.roomMessageStatusUpdated(messageID: UUID()) + + #expect(!viewModel.shouldRefreshFailedSendIndicators(for: event)) + + viewModel.applyFailedSendKeys(FailedSendConversationKeys(roomSessionIDs: [UUID()])) + + #expect(viewModel.shouldRefreshFailedSendIndicators(for: event)) + } + + @Test + func `other message events do not refresh`() { + let viewModel = makeViewModel() + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [UUID()])) + let messageID = UUID() + let contact = makeContact() + let message = makeMessage(radioID: contact.radioID, contactID: contact.id) + + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .directMessageReceived(message: message, contact: contact) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .channelMessageReceived(message: message, channelIndex: 0) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .roomMessageReceived( + message: RoomMessageDTO( + sessionID: UUID(), + authorKeyPrefix: Data([0x01, 0x02, 0x03, 0x04]), + text: "hi", + timestamp: 1 + ), + sessionID: UUID() + ) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .messageRetrying(messageID: messageID, attempt: 1, maxAttempts: 4) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .heardRepeatRecorded(messageID: messageID, count: 1) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .reactionReceived(messageID: messageID, summary: "👍:1") + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .messagesRegionUpdated(messageIDs: [messageID]) + )) + #expect(!viewModel.shouldRefreshFailedSendIndicators( + for: .routingChanged(contactID: contact.id, isFlood: true) + )) + } + + /// Refresh #1 fetches empty keys and parks; #2 then fetches the saved failed + /// send and applies it. #1 must drop its empty result instead of wiping #2. + @Test + func `stale empty refresh does not overwrite a newer apply`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + + let arrived = AsyncGate() + let gate = AsyncGate() + viewModel.failedSendRefreshInterleaveHook = { + await arrived.open() + await gate.wait() + } + + let first = Task { await viewModel.refreshFailedSendIndicators() } + await arrived.wait() + + viewModel.failedSendRefreshInterleaveHook = nil + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(contact.id)) + + await gate.open() + await first.value + + #expect(viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `cancelled in-flight refresh does not apply fetched keys`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + + let arrived = AsyncGate() + let gate = AsyncGate() + viewModel.failedSendRefreshInterleaveHook = { + await arrived.open() + await gate.wait() + } + + let task = Task { await viewModel.refreshFailedSendIndicators() } + await arrived.wait() + task.cancel() + await gate.open() + await task.value + + #expect(viewModel.failedSendConversationIDs.isEmpty) + } + + @Test + func `store throw leaves last-known badges`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.applyFailedSendKeys(FailedSendConversationKeys(contactIDs: [contact.id])) + viewModel.failedSendRefreshFaultInjection = { throw TestStoreError() } + + await viewModel.refreshFailedSendIndicators() + + #expect(viewModel.conversationHasFailedSend(contact.id)) + } + + /// Event-driven refresh is not `reloadTask`; clear must bump generation so a + /// parked fetch cannot re-dirty `failedSendConversationIDs` after disconnect. + @Test + func `clearConversations drops an in-flight refresh apply`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + + let arrived = AsyncGate() + let gate = AsyncGate() + viewModel.failedSendRefreshInterleaveHook = { + await arrived.open() + await gate.wait() + } + + let task = Task { await viewModel.refreshFailedSendIndicators() } + await arrived.wait() + viewModel.clearConversations() + #expect(viewModel.failedSendConversationIDs.isEmpty) + + await gate.open() + await task.value + + #expect(viewModel.failedSendConversationIDs.isEmpty) + } + + @Test + func `loadMessages marks failed sends seen; prime does not`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + try await store.saveContact(contact) + try await store.saveMessage( + makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + ) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + viewModel.conversations = [contact] + + _ = await viewModel.primeInitialMessages(for: contact, populateMode: .replace) + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(contact.id)) + + await viewModel.loadMessages(for: contact, populateMode: .replace) + await viewModel.refreshFailedSendIndicators() + #expect(!viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `loadChannelMessages marks failed sends seen`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let channel = makeChannel(radioID: radioID, index: 3) + try await store.saveChannel(channel) + try await store.saveMessage( + makeMessage(radioID: radioID, channelIndex: channel.index, status: .failed) + ) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.bindCoordinatorForTesting(ChatCoordinator.makeForTesting()) + viewModel.channels = [channel] + + await viewModel.loadChannelMessages(for: channel, populateMode: .replace) + await viewModel.refreshFailedSendIndicators() + #expect(!viewModel.conversationHasFailedSend(channel.id)) + } + + @Test + func `handle messageFailed for the current contact marks seen`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let message = makeMessage(radioID: radioID, contactID: contact.id, status: .failed) + try await store.saveContact(contact) + try await store.saveMessage(message) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.currentContact = contact + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(contact.id)) + + await viewModel.handle(.messageFailed(messageID: message.id)) + await viewModel.refreshFailedSendIndicators() + #expect(!viewModel.conversationHasFailedSend(contact.id)) + } + + @Test + func `handle messageFailed for another contact does not mark seen`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let openContact = makeContact(radioID: radioID) + let otherContact = makeContact(radioID: radioID) + let message = makeMessage(radioID: radioID, contactID: otherContact.id, status: .failed) + try await store.saveContact(otherContact) + try await store.saveMessage(message) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [openContact, otherContact] + viewModel.currentContact = openContact + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(otherContact.id)) + + await viewModel.handle(.messageFailed(messageID: message.id)) + await viewModel.refreshFailedSendIndicators() + #expect(viewModel.conversationHasFailedSend(otherContact.id)) + } + + @Test + func `recordLocalEnqueueFailure marks the current contact seen`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let message = makeMessage(radioID: radioID, contactID: contact.id, status: .pending) + try await store.saveContact(contact) + try await store.saveMessage(message) + + let viewModel = makeViewModel() + viewModel.configureForTesting( + dependencies: .testDefaults( + dataStore: { store }, + currentRadioID: { radioID } + ) + ) + viewModel.conversations = [contact] + viewModel.currentContact = contact + + await viewModel.recordLocalEnqueueFailure( + messageID: message.id, + error: ChatSendQueueServiceError.notConnected + ) + await viewModel.refreshFailedSendIndicators() + + #expect(!viewModel.conversationHasFailedSend(contact.id)) + #expect(viewModel.sendErrorMessage == L10n.Chats.Chats.Alert.UnableToSend.message) + let stored = try await store.fetchMessage(id: message.id) + #expect(stored?.status == .failed) + #expect(stored?.failureSeen == true) + } +} + +private struct TestStoreError: Error {} + +/// Parks a failed-send refresh between its fetch and apply. +private actor AsyncGate { + private var waiter: CheckedContinuation? + private var opened = false + + func wait() async { + if opened { return } + await withCheckedContinuation { waiter = $0 } + } + + func open() { + opened = true + waiter?.resume() + waiter = nil + } +} From ea7b58d98c9a8c11a499bc5e2fa758a81d69dd0e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:37:35 -0700 Subject: [PATCH 34/47] test(contacts): keep avatars on batch save and reseed - batchSaveContacts must not wipe existing avatarImageData - simulator reseed must keep a user-set avatar --- .../PersistenceStoreBatchSyncTests.swift | 22 +++++++++++++++++++ .../MC1ServicesTests/SimulatorSeedTests.swift | 18 +++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift index e20970d1d..2598520d2 100644 --- a/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/PersistenceStoreBatchSyncTests.swift @@ -215,6 +215,28 @@ struct PersistenceStoreBatchSyncTests { #expect(contact.isFavorite) } + @Test + func `batchSaveContacts preserves existing avatarImageData`() async throws { + let radioID = UUID() + let store = try await PersistenceStore.createTestDataStore(radioID: radioID, maxChannels: 8) + let jpeg = Data(repeating: 0xAB, count: 32) + let contact = ContactDTO.testContact( + radioID: radioID, + publicKey: publicKey(0x01), + name: "Original", + avatarImageData: jpeg + ) + try await store.saveContact(contact) + + _ = try await store.batchSaveContacts(radioID: radioID, from: [ + contactFrame(0x01, name: "Renamed") + ]) + + let stored = try #require(try await store.fetchContact(radioID: radioID, publicKey: publicKey(0x01))) + #expect(stored.name == "Renamed") + #expect(stored.avatarImageData == jpeg) + } + @Test func `batchSaveContacts with empty frames returns zero`() async throws { let radioID = UUID() diff --git a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift index d152af138..0af4d5859 100644 --- a/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/SimulatorSeedTests.swift @@ -111,6 +111,24 @@ struct SimulatorSeedTests { #expect(snapshots.contains { $0.latitude != nil && $0.longitude != nil }) } + @Test + func `reseeding preserves a user-set avatarImageData`() async throws { + let container = try PersistenceStore.createContainer(inMemory: true) + let store = PersistenceStore(modelContainer: container) + let mode = SimulatorConnectionMode() + try await mode.seedDataStore(store) + + let jpeg = Data(repeating: 0xCD, count: 16) + let aliceID = MockDataProvider.aliceChenID + let existing = try #require(try await store.fetchContact(id: aliceID)) + try await store.saveContact(existing.with(avatarImageData: jpeg)) + #expect(try await store.fetchContact(id: aliceID)?.avatarImageData == jpeg) + + try await mode.seedDataStore(store) + + #expect(try await store.fetchContact(id: aliceID)?.avatarImageData == jpeg) + } + @Test func `reseeding is idempotent`() async throws { let container = try PersistenceStore.createContainer(inMemory: true) From 90ec929abd7ff3fdaabe43729323c8bc51104d80 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:43:51 -0700 Subject: [PATCH 35/47] test(chats): pin failed-send mark-seen isolation - Channel mark leaves other indexes and the same index on another radio - Room mark leaves the unmarked session --- .../FailedSendConversationKeysTests.swift | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift b/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift index 73e1b2b76..f34598a34 100644 --- a/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/FailedSendConversationKeysTests.swift @@ -261,31 +261,57 @@ struct FailedSendConversationKeysTests { } @Test - func `markFailedSendsSeen drops a room session`() async throws { + func `markFailedSendsSeen drops only that channel index on that radio`() async throws { + let store = try await createTestStore() + let radioA = UUID() + let radioB = UUID() + let channelA0 = ChannelDTO.testChannel(radioID: radioA, index: 0, name: "A0") + let channelA1 = ChannelDTO.testChannel(radioID: radioA, index: 1, name: "A1") + let channelB0 = ChannelDTO.testChannel(radioID: radioB, index: 0, name: "B0") + try await store.saveChannel(channelA0) + try await store.saveChannel(channelA1) + try await store.saveChannel(channelB0) + try await store.saveMessage( + .testChannelMessage(radioID: radioA, channelIndex: 0, status: .failed) + ) + try await store.saveMessage( + .testChannelMessage(radioID: radioA, channelIndex: 1, status: .failed) + ) + try await store.saveMessage( + .testChannelMessage(radioID: radioB, channelIndex: 0, status: .failed) + ) + + try await store.markFailedSendsSeen(radioID: radioA, channelIndex: 0) + let keysA = try await store.fetchFailedSendConversationKeys(radioID: radioA) + let keysB = try await store.fetchFailedSendConversationKeys(radioID: radioB) + + #expect(keysA.channelIDs == [channelA1.id]) + #expect(keysB.channelIDs == [channelB0.id]) + } + + @Test + func `markFailedSendsSeen drops only that room session`() async throws { let store = try await createTestStore() let radioID = UUID() - let sessionID = UUID() - try await store.saveRemoteNodeSessionDTO( - RemoteNodeSessionDTO( - id: sessionID, - radioID: radioID, - publicKey: Data(repeating: 0x11, count: 32), - name: "Room", - role: .roomServer - ) + let seen = RemoteNodeSessionDTO.testSession(radioID: radioID) + let other = RemoteNodeSessionDTO.testSession( + radioID: radioID, + publicKey: Data(repeating: 0xDD, count: 32), + name: "OtherRoom" ) + try await store.saveRemoteNodeSessionDTO(seen) + try await store.saveRemoteNodeSessionDTO(other) try await store.saveRoomMessage( - .testRoomMessage( - sessionID: sessionID, - isFromSelf: true, - status: .failed - ) + .testRoomMessage(sessionID: seen.id, isFromSelf: true, status: .failed) + ) + try await store.saveRoomMessage( + .testRoomMessage(sessionID: other.id, isFromSelf: true, status: .failed) ) - try await store.markFailedSendsSeen(roomSessionID: sessionID) + try await store.markFailedSendsSeen(roomSessionID: seen.id) let keys = try await store.fetchFailedSendConversationKeys(radioID: radioID) - #expect(keys.roomSessionIDs.isEmpty) + #expect(keys.roomSessionIDs == [other.id]) } @Test From 371a834af051e2b9a7c07e5d102e07a4d3aa35f8 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:56:06 -0700 Subject: [PATCH 36/47] fix(chats): hold compose lift during swipe-back - Swipe-back posted willHide while the keyboard window stayed, dropping the compose bar mid-screen - Hold current lift during an interactive pop; back-button and keyboard drag still apply the proposed frame --- .../Chats/Components/ChatKeyboardLift.swift | 113 +++++++++++++++++- .../Components/ChatKeyboardLiftTests.swift | 30 +++++ 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/MC1/Views/Chats/Components/ChatKeyboardLift.swift b/MC1/Views/Chats/Components/ChatKeyboardLift.swift index af1d43058..3eb2867fa 100644 --- a/MC1/Views/Chats/Components/ChatKeyboardLift.swift +++ b/MC1/Views/Chats/Components/ChatKeyboardLift.swift @@ -66,6 +66,19 @@ enum ChatKeyboardLift { for: nil ) } + + /// Interactive pop posts willHide while the keyboard window stays; keep the + /// current lift. Back-button and drag-to-dismiss still apply `proposed`. + static func resolvedLift( + current: CGFloat, + proposed: CGFloat, + isInteractivePopActive: Bool + ) -> CGFloat { + if isInteractivePopActive, proposed < current { + return current + } + return proposed + } } extension EnvironmentValues { @@ -79,11 +92,18 @@ private struct ChatKeyboardOwnedLiftModifier: ViewModifier { @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var lift: CGFloat = 0 + @State private var interactivePop = InteractivePopProbe() func body(content: Content) -> some View { content .environment(\.chatKeyboardLift, lift) .ignoresSafeArea(.keyboard, edges: .bottom) + .background { + InteractiveNavigationPopObserver(probe: interactivePop) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .allowsHitTesting(false) + } .onReceive( NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification) ) { notification in @@ -92,7 +112,7 @@ private struct ChatKeyboardOwnedLiftModifier: ViewModifier { .onReceive( NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) ) { notification in - setLift(0, userInfo: notification.userInfo) + applyProposedLift(0, userInfo: notification.userInfo) } .onChange(of: scenePhase) { _, phase in switch phase { @@ -113,7 +133,7 @@ private struct ChatKeyboardOwnedLiftModifier: ViewModifier { let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, let window = ChatKeyboardLift.keyWindow() else { - setLift(0, userInfo: notification.userInfo) + applyProposedLift(0, userInfo: notification.userInfo) return } let keyboardInWindow = window.convert(frame, from: nil) @@ -122,7 +142,16 @@ private struct ChatKeyboardOwnedLiftModifier: ViewModifier { windowBounds: window.bounds, bottomSafeArea: window.safeAreaInsets.bottom ) - setLift(next, userInfo: notification.userInfo) + applyProposedLift(next, userInfo: notification.userInfo) + } + + private func applyProposedLift(_ proposed: CGFloat, userInfo: [AnyHashable: Any]?) { + let next = ChatKeyboardLift.resolvedLift( + current: lift, + proposed: proposed, + isInteractivePopActive: interactivePop.isInteractivePopActive + ) + setLift(next, userInfo: userInfo) } private func setLift(_ value: CGFloat, userInfo: [AnyHashable: Any]?) { @@ -150,6 +179,84 @@ private struct ChatKeyboardLiftPaddingModifier: ViewModifier { } } +/// Live pop signal: `initiallyInteractive` covers swipe through cancel/commit; +/// gesture `.began`/`.changed` covers willHide before a coordinator exists. +@MainActor +private final class InteractivePopProbe { + weak var navigationController: UINavigationController? + + var isInteractivePopActive: Bool { + guard let navigationController else { return false } + if navigationController.transitionCoordinator?.initiallyInteractive == true { + return true + } + if isPopGestureActive(navigationController.interactivePopGestureRecognizer) { + return true + } + if #available(iOS 26, *) { + if isPopGestureActive(navigationController.interactiveContentPopGestureRecognizer) { + return true + } + } + return false + } + + private func isPopGestureActive(_ gesture: UIGestureRecognizer?) -> Bool { + switch gesture?.state { + case .began, .changed: + true + default: + false + } + } +} + +private struct InteractiveNavigationPopObserver: UIViewControllerRepresentable { + let probe: InteractivePopProbe + + func makeUIViewController(context: Context) -> Controller { + Controller(probe: probe) + } + + func updateUIViewController(_ uiViewController: Controller, context: Context) { + uiViewController.probe = probe + } + + static func dismantleUIViewController(_ uiViewController: Controller, coordinator: Void) { + uiViewController.clearNavigationController() + } + + final class Controller: UIViewController { + var probe: InteractivePopProbe + + init(probe: InteractivePopProbe) { + self.probe = probe + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + probe.navigationController = navigationController + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + clearNavigationController() + } + + func clearNavigationController() { + if probe.navigationController === navigationController { + probe.navigationController = nil + } + } + } +} + extension View { /// Owns keyboard avoidance for chat conversation hosts: ignores system /// keyboard safe area and publishes `chatKeyboardLift` for bottom chrome. diff --git a/MC1Tests/Views/Chats/Components/ChatKeyboardLiftTests.swift b/MC1Tests/Views/Chats/Components/ChatKeyboardLiftTests.swift index f79c8b8d6..8b61ab994 100644 --- a/MC1Tests/Views/Chats/Components/ChatKeyboardLiftTests.swift +++ b/MC1Tests/Views/Chats/Components/ChatKeyboardLiftTests.swift @@ -130,4 +130,34 @@ struct ChatKeyboardLiftTests { ) #expect(animation == nil) } + + @Test + func `interactive pop keeps current lift on a drop`() { + let resolved = ChatKeyboardLift.resolvedLift( + current: 302, + proposed: 0, + isInteractivePopActive: true + ) + #expect(resolved == 302) + } + + @Test + func `hide without interactive pop applies proposed lift`() { + let resolved = ChatKeyboardLift.resolvedLift( + current: 302, + proposed: 0, + isInteractivePopActive: false + ) + #expect(resolved == 0) + } + + @Test + func `interactive pop still applies a higher lift`() { + let resolved = ChatKeyboardLift.resolvedLift( + current: 200, + proposed: 302, + isInteractivePopActive: true + ) + #expect(resolved == 302) + } } From d894e6429f7942b4c18dbd9d23438a6d2b4229d1 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:57:21 -0700 Subject: [PATCH 37/47] fix(settings): radio menu uses settings stack - Open Advanced Settings via navigateToSetting(.advanced) - Drop the toolbar's private AdvancedSettingsView destination --- MC1/Views/Components/BLEStatusIndicatorView.swift | 6 +----- MC1/Views/Settings/SettingsSubpage.swift | 3 +-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/MC1/Views/Components/BLEStatusIndicatorView.swift b/MC1/Views/Components/BLEStatusIndicatorView.swift index 09cc9ca68..371304074 100644 --- a/MC1/Views/Components/BLEStatusIndicatorView.swift +++ b/MC1/Views/Components/BLEStatusIndicatorView.swift @@ -10,7 +10,6 @@ private let logger = Logger(subsystem: "com.mc1", category: "BLEStatus") 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 @@ -42,9 +41,6 @@ struct BLEStatusIndicatorView: View { .presentationDetents([.medium]) .presentationDragIndicator(.visible) } - .navigationDestination(isPresented: $showingAdvancedSettings) { - AdvancedSettingsView() - } } // MARK: - Menu Content @@ -106,7 +102,7 @@ struct BLEStatusIndicatorView: View { Section { Button { - showingAdvancedSettings = true + appState.navigation.navigateToSetting(.advanced) } label: { Label(L10n.Settings.AdvancedSettings.title, systemImage: "gearshape") } diff --git a/MC1/Views/Settings/SettingsSubpage.swift b/MC1/Views/Settings/SettingsSubpage.swift index c504d42c0..53fd1b22a 100644 --- a/MC1/Views/Settings/SettingsSubpage.swift +++ b/MC1/Views/Settings/SettingsSubpage.swift @@ -15,8 +15,7 @@ enum SettingsSubpage: Hashable { 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`). + /// hosts the page (the compact Settings stack and the iPad detail column). @MainActor func settingsSubpageDestinations() -> some View { navigationDestination(for: SettingsSubpage.self) { subpage in From 1a28e10da68cf9cc98f87137e8e7a68e4045cda8 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:43:11 -0700 Subject: [PATCH 38/47] feat(chats): add on-device message translation - Detect language at bake time and show an in-bubble Translate control - Keep stored message text as-is and cache translations per target --- MC1/Resources/Generated/L10n.swift | 10 + .../Localization/de.lproj/Chats.strings | 12 + .../Localization/en.lproj/Chats.strings | 12 + .../Localization/es.lproj/Chats.strings | 12 + .../Localization/fr.lproj/Chats.strings | 12 + .../Localization/it.lproj/Chats.strings | 12 + .../Localization/nl.lproj/Chats.strings | 12 + .../Localization/pl.lproj/Chats.strings | 12 + .../Localization/pt.lproj/Chats.strings | 12 + .../Localization/ru.lproj/Chats.strings | 12 + .../Localization/uk.lproj/Chats.strings | 12 + .../Localization/zh-Hans.lproj/Chats.strings | 12 + MC1/Services/MessageTranslating.swift | 8 + ...MessageTranslationNeedsDownloadError.swift | 13 + .../TranslationLanguageResolver.swift | 71 ++++ MC1/Services/TranslationPerformResult.swift | 6 + .../TranslationSession+Configuration.swift | 27 ++ MC1/Services/TranslationSessionLauncher.swift | 90 +++++ MC1/Services/TranslationSessionRequest.swift | 10 + MC1/State/AppState+ChatPrefetch.swift | 3 +- .../ChatConversationMessagesContent.swift | 5 +- MC1/Views/Chats/ChatConversationView.swift | 32 +- .../Chats/Components/BubbleActions.swift | 1 + .../Components/BubbleFragmentStack.swift | 18 +- .../Components/BubbleTranslationControl.swift | 58 +++ ...nversationTranslationSessionModifier.swift | 71 ++++ .../Components/MessageBubbleCallbacks.swift | 1 + .../Chats/Components/MessageBubbleView.swift | 3 +- .../Components/UnifiedMessageBubble.swift | 23 +- MC1/Views/Chats/Reactions/MessageAction.swift | 1 + .../Sections/ActionsButtonsSection.swift | 6 + .../Timeline/ChatTimeline+BakeUpdates.swift | 6 + .../ChatMessageBakeState+ItemBuild.swift | 15 +- .../ViewModel/ChatMessageBakeState.swift | 12 + .../ViewModel/ChatViewModel+Translation.swift | 160 ++++++++ MC1/Views/Chats/ViewModel/ChatViewModel.swift | 14 + .../Rooms/RoomConversationView.swift | 88 +++-- ...oomConversationViewModel+Translation.swift | 176 +++++++++ .../Rooms/RoomConversationViewModel.swift | 35 +- .../RemoteNodes/Rooms/RoomMessageAction.swift | 1 + .../Rooms/RoomMessageActionAvailability.swift | 5 +- .../Rooms/RoomMessageActionsSheet.swift | 6 + .../RemoteNodes/Rooms/RoomMessageBubble.swift | 64 +++- .../RemoteNodes/Rooms/RoomTiledRow.swift | 1 + .../Models/Rendering/DetectedLanguage.swift | 15 + .../Models/Rendering/EnvInputs.swift | 18 +- .../Models/Rendering/MessageBuildInputs.swift | 7 +- .../Models/Rendering/MessageItem.swift | 9 + .../Models/Rendering/MessageTextPayload.swift | 7 +- .../Rendering/MessageTranslationChrome.swift | 45 +++ .../Services/MessageFragmentBuilder.swift | 3 +- .../Services/MessageLanguageDetector.swift | 41 +++ .../EnvInputsThemeTokenTests.swift | 36 +- .../MessageLanguageDetectorTests.swift | 46 +++ .../MessageTranslationChromeTests.swift | 111 ++++++ .../TranslationLanguageResolverTests.swift | 87 +++++ ...TranslationSessionConfigurationTests.swift | 26 ++ .../TranslationSessionLauncherTests.swift | 67 ++++ .../ChatViewModelPaginationTests.swift | 3 +- MC1Tests/ViewModels/ChatViewModelTests.swift | 12 +- .../ChatViewModelTranslationTests.swift | 346 ++++++++++++++++++ .../ViewModels/GatedMessageTranslator.swift | 40 ++ ...onversationViewModelTranslationTests.swift | 143 ++++++++ MC1Tests/Views/Chats/ChatTimelineTests.swift | 41 +++ .../Chats/ChatViewModelAdmissionTests.swift | 3 +- .../Components/MessageBubbleTestData.swift | 3 +- .../MessageFragmentBuilderFixtures.swift | 3 +- .../Models/MessageFragmentBuilderTests.swift | 57 ++- 68 files changed, 2275 insertions(+), 66 deletions(-) create mode 100644 MC1/Services/MessageTranslating.swift create mode 100644 MC1/Services/MessageTranslationNeedsDownloadError.swift create mode 100644 MC1/Services/TranslationLanguageResolver.swift create mode 100644 MC1/Services/TranslationPerformResult.swift create mode 100644 MC1/Services/TranslationSession+Configuration.swift create mode 100644 MC1/Services/TranslationSessionLauncher.swift create mode 100644 MC1/Services/TranslationSessionRequest.swift create mode 100644 MC1/Views/Chats/Components/BubbleTranslationControl.swift create mode 100644 MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift create mode 100644 MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift create mode 100644 MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift create mode 100644 MC1Services/Sources/MC1Services/Models/Rendering/DetectedLanguage.swift create mode 100644 MC1Services/Sources/MC1Services/Models/Rendering/MessageTranslationChrome.swift create mode 100644 MC1Services/Sources/MC1Services/Services/MessageLanguageDetector.swift create mode 100644 MC1Services/Tests/MC1ServicesTests/MessageLanguageDetectorTests.swift create mode 100644 MC1Services/Tests/MC1ServicesTests/MessageTranslationChromeTests.swift create mode 100644 MC1Tests/Services/TranslationLanguageResolverTests.swift create mode 100644 MC1Tests/Services/TranslationSessionConfigurationTests.swift create mode 100644 MC1Tests/Services/TranslationSessionLauncherTests.swift create mode 100644 MC1Tests/ViewModels/ChatViewModelTranslationTests.swift create mode 100644 MC1Tests/ViewModels/GatedMessageTranslator.swift create mode 100644 MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 51316fa38..24385f531 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -585,6 +585,10 @@ public enum L10n { } } public enum Message { + /// Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text + public static func translatedAccessibility(_ p1: Any) -> String { + return L10n.tr("Chats", "chats.message.translatedAccessibility", String(describing: p1), fallback: "Translated: %@") + } /// Location: ChatConversationView.swift - Placeholder when message data is unavailable public static let unavailable = L10n.tr("Chats", "chats.message.unavailable", fallback: "Message unavailable") /// Location: ChatConversationView.swift - Accessibility label for unavailable message @@ -640,6 +644,12 @@ public enum L10n { public static let sendAgain = L10n.tr("Chats", "chats.message.action.sendAgain", fallback: "Send Again") /// Location: MessageActionsSheet.swift - Purpose: Action to start a DM with the channel sender public static let sendDM = L10n.tr("Chats", "chats.message.action.sendDM", fallback: "Send DM") + /// Location: BubbleTranslationControl.swift - Restore the stored message body + public static let showOriginal = L10n.tr("Chats", "chats.message.action.showOriginal", fallback: "Show original") + /// Location: BubbleTranslationControl.swift - In-bubble Translation offer + public static let translate = L10n.tr("Chats", "chats.message.action.translate", fallback: "Translate") + /// Location: BubbleTranslationControl.swift - Translation in progress + public static let translating = L10n.tr("Chats", "chats.message.action.translating", fallback: "Translating") /// Location: UnifiedMessageBubble.swift - VoiceOver action to open an attached image full-screen public static let viewImage = L10n.tr("Chats", "chats.message.action.viewImage", fallback: "View Image") /// Location: UnifiedMessageBubble.swift - Context menu action to view path diff --git a/MC1/Resources/Localization/de.lproj/Chats.strings b/MC1/Resources/Localization/de.lproj/Chats.strings index 9687d42d2..c9a190d27 100644 --- a/MC1/Resources/Localization/de.lproj/Chats.strings +++ b/MC1/Resources/Localization/de.lproj/Chats.strings @@ -565,6 +565,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Kopieren"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Übersetzen"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Original anzeigen"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Wird übersetzt..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Übersetzt: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Wiederholungsdetails"; diff --git a/MC1/Resources/Localization/en.lproj/Chats.strings b/MC1/Resources/Localization/en.lproj/Chats.strings index 46e53fb43..b440a823d 100644 --- a/MC1/Resources/Localization/en.lproj/Chats.strings +++ b/MC1/Resources/Localization/en.lproj/Chats.strings @@ -594,6 +594,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Copy"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Translate"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Show original"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Translating"; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Translated: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Repeat Details"; diff --git a/MC1/Resources/Localization/es.lproj/Chats.strings b/MC1/Resources/Localization/es.lproj/Chats.strings index 5161a26b2..c20d4e729 100644 --- a/MC1/Resources/Localization/es.lproj/Chats.strings +++ b/MC1/Resources/Localization/es.lproj/Chats.strings @@ -565,6 +565,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Copiar"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Traducir"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Mostrar original"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Traduciendo..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Traducido: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Detalles de repetición"; diff --git a/MC1/Resources/Localization/fr.lproj/Chats.strings b/MC1/Resources/Localization/fr.lproj/Chats.strings index c77589c38..621734e9d 100644 --- a/MC1/Resources/Localization/fr.lproj/Chats.strings +++ b/MC1/Resources/Localization/fr.lproj/Chats.strings @@ -565,6 +565,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Copier"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Traduire"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Afficher l'original"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Traduction..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Traduit : %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Détails des répétitions"; diff --git a/MC1/Resources/Localization/it.lproj/Chats.strings b/MC1/Resources/Localization/it.lproj/Chats.strings index 8ebd7ee4e..1c2576647 100644 --- a/MC1/Resources/Localization/it.lproj/Chats.strings +++ b/MC1/Resources/Localization/it.lproj/Chats.strings @@ -594,6 +594,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Copia"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Traduci"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Mostra originale"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Traduzione..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Tradotto: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Dettagli ripetizioni"; diff --git a/MC1/Resources/Localization/nl.lproj/Chats.strings b/MC1/Resources/Localization/nl.lproj/Chats.strings index de8056ac4..6f7b0a409 100644 --- a/MC1/Resources/Localization/nl.lproj/Chats.strings +++ b/MC1/Resources/Localization/nl.lproj/Chats.strings @@ -565,6 +565,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Kopiëren"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Vertalen"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Origineel tonen"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Vertalen..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Vertaald: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Herhalingsdetails"; diff --git a/MC1/Resources/Localization/pl.lproj/Chats.strings b/MC1/Resources/Localization/pl.lproj/Chats.strings index 195c15bc5..78798ea9d 100644 --- a/MC1/Resources/Localization/pl.lproj/Chats.strings +++ b/MC1/Resources/Localization/pl.lproj/Chats.strings @@ -560,6 +560,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Kopiuj"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Tłumacz"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Pokaż oryginał"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Tłumaczenie..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Przetłumaczono: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Szczegóły powtórzeń"; diff --git a/MC1/Resources/Localization/pt.lproj/Chats.strings b/MC1/Resources/Localization/pt.lproj/Chats.strings index ccb8726b4..5b757d974 100644 --- a/MC1/Resources/Localization/pt.lproj/Chats.strings +++ b/MC1/Resources/Localization/pt.lproj/Chats.strings @@ -594,6 +594,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Copiar"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Traduzir"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Mostrar original"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "A traduzir..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Traduzido: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Detalhes da repetição"; diff --git a/MC1/Resources/Localization/ru.lproj/Chats.strings b/MC1/Resources/Localization/ru.lproj/Chats.strings index c5ee65a5e..2cc11d44a 100644 --- a/MC1/Resources/Localization/ru.lproj/Chats.strings +++ b/MC1/Resources/Localization/ru.lproj/Chats.strings @@ -559,6 +559,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Копировать"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Перевести"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Показать оригинал"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Перевод..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Переведено: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Детали повторов"; diff --git a/MC1/Resources/Localization/uk.lproj/Chats.strings b/MC1/Resources/Localization/uk.lproj/Chats.strings index 890594cdc..dacede36d 100644 --- a/MC1/Resources/Localization/uk.lproj/Chats.strings +++ b/MC1/Resources/Localization/uk.lproj/Chats.strings @@ -559,6 +559,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "Копіювати"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "Перекласти"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "Показати оригінал"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "Переклад..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "Перекладено: %@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "Деталі повторів"; diff --git a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings index 65a5bd022..a0bb935c0 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/Chats.strings @@ -594,6 +594,18 @@ /* Location: UnifiedMessageBubble.swift - Context menu action to copy */ "chats.message.action.copy" = "复制"; +/* Location: BubbleTranslationControl.swift - In-bubble Translation offer */ +"chats.message.action.translate" = "翻译"; + +/* Location: BubbleTranslationControl.swift - Restore the stored message body */ +"chats.message.action.showOriginal" = "显示原文"; + +/* Location: BubbleTranslationControl.swift - Translation in progress */ +"chats.message.action.translating" = "翻译中..."; + +/* Location: UnifiedMessageBubble.swift - VoiceOver prefix when the visible body is a translation - %@ is the translated text */ +"chats.message.translatedAccessibility" = "已翻译:%@"; + /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ "chats.message.action.repeatDetails" = "转发详情"; diff --git a/MC1/Services/MessageTranslating.swift b/MC1/Services/MessageTranslating.swift new file mode 100644 index 000000000..6caf0e083 --- /dev/null +++ b/MC1/Services/MessageTranslating.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Test seam for on-device translation. Production wraps `TranslationSession` +/// inside the conversation `.translationTask` closure; tests inject a fake. +@MainActor +protocol MessageTranslating { + func translate(_ text: String) async throws -> String +} diff --git a/MC1/Services/MessageTranslationNeedsDownloadError.swift b/MC1/Services/MessageTranslationNeedsDownloadError.swift new file mode 100644 index 000000000..a1d3dd0e1 --- /dev/null +++ b/MC1/Services/MessageTranslationNeedsDownloadError.swift @@ -0,0 +1,13 @@ +import Foundation +@preconcurrency import Translation + +/// Installed-only session could not translate. Leave the pending request in +/// place so `.translationTask` can present the system download UI. +struct MessageTranslationNeedsDownloadError: Error { + static func wrapping(_ error: Error) -> Error { + if #available(iOS 26.0, *), TranslationError.notInstalled ~= error { + return MessageTranslationNeedsDownloadError() + } + return error + } +} diff --git a/MC1/Services/TranslationLanguageResolver.swift b/MC1/Services/TranslationLanguageResolver.swift new file mode 100644 index 000000000..bd6734d72 --- /dev/null +++ b/MC1/Services/TranslationLanguageResolver.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Maps compact detector/app-locale codes onto `LanguageAvailability.supportedLanguages`. +/// Compact codes make `status` report `.supported` even when the regional pack is installed. +enum TranslationLanguageResolver { + private enum Score { + static let specifiedScript = 4 + static let specifiedRegion = 2 + static let preferredRegion = 1 + } + + static func resolve( + _ code: String, + from supported: [Locale.Language], + preferring locale: Locale = .current + ) -> Locale.Language { + let requested = Locale.Language(identifier: code) + let matches = supported.filter { $0.languageCode == requested.languageCode } + guard !matches.isEmpty else { return requested } + + if !isCompact(code), + let exact = matches.first(where: { $0.maximalIdentifier == requested.maximalIdentifier }) { + return exact + } + + var best = matches[0] + var bestScore = score(best, code: code, requested: requested, locale: locale) + for candidate in matches.dropFirst() { + let value = score(candidate, code: code, requested: requested, locale: locale) + if value > bestScore { + best = candidate + bestScore = value + } + } + return best + } + + /// Language subtag only (`zh`, `en`). Foundation fills a default script/region + /// on `maximalIdentifier`, which must not count as an exact pack match. + private static func isCompact(_ code: String) -> Bool { + !code.contains("-") + } + + private static func score( + _ candidate: Locale.Language, + code: String, + requested: Locale.Language, + locale: Locale + ) -> Int { + var value = 0 + if isCompact(code) { + if candidate.script != nil, candidate.script == locale.language.script { + value += Score.specifiedScript + } + if candidate.region != nil, candidate.region == locale.region { + value += Score.preferredRegion + } + } else { + if candidate.script != nil, candidate.script == requested.script { + value += Score.specifiedScript + } + if requested.region != nil, candidate.region == requested.region { + value += Score.specifiedRegion + } + if requested.region == nil, candidate.region == locale.region { + value += Score.preferredRegion + } + } + return value + } +} diff --git a/MC1/Services/TranslationPerformResult.swift b/MC1/Services/TranslationPerformResult.swift new file mode 100644 index 000000000..06e0c1f9c --- /dev/null +++ b/MC1/Services/TranslationPerformResult.swift @@ -0,0 +1,6 @@ +import Foundation + +enum TranslationPerformResult: Equatable, Sendable { + case finished + case needsDownload +} diff --git a/MC1/Services/TranslationSession+Configuration.swift b/MC1/Services/TranslationSession+Configuration.swift new file mode 100644 index 000000000..4113c8ce8 --- /dev/null +++ b/MC1/Services/TranslationSession+Configuration.swift @@ -0,0 +1,27 @@ +import Foundation +import Translation + +extension TranslationSession.Configuration { + /// Copy-mutate-reassign so a same-pair second message re-runs `.translationTask`. + /// `invalidate()` is mutating on a struct; optional-struct mutate in place does not publish. + static func replacing( + _ current: Self?, + source: Locale.Language, + target: Locale.Language + ) -> Self { + if var config = current, config.source == source, config.target == target { + config.invalidate() + return config + } + // highFidelity is Apple Intelligence; lowLatency is Translate language packs. + // Installed probe tries both; `.translationTask` prefers Intelligence. + if #available(iOS 26.4, *) { + return TranslationSession.Configuration( + source: source, + target: target, + preferredStrategy: .highFidelity + ) + } + return TranslationSession.Configuration(source: source, target: target) + } +} diff --git a/MC1/Services/TranslationSessionLauncher.swift b/MC1/Services/TranslationSessionLauncher.swift new file mode 100644 index 000000000..70bd36542 --- /dev/null +++ b/MC1/Services/TranslationSessionLauncher.swift @@ -0,0 +1,90 @@ +import Foundation +@preconcurrency import Translation + +/// Installed-only `translate()` first. `.translationTask` can present a download +/// UI even when the pack is on disk, so fall back only on `notInstalled`. +enum TranslationSessionLauncher { + /// Runs `perform` on an installed-only session, or returns a `.translationTask` configuration. + @MainActor + static func launch( + request: TranslationSessionRequest, + replacing configuration: TranslationSession.Configuration?, + perform: (any MessageTranslating) async -> TranslationPerformResult + ) async -> TranslationSession.Configuration? { + let supported = await Self.translatePackLanguages() + guard !Task.isCancelled else { return nil } + let source = TranslationLanguageResolver.resolve( + request.sourceLanguageCode, + from: supported + ) + let target = TranslationLanguageResolver.resolve( + request.targetLanguageCode, + from: supported + ) + + if #available(iOS 26.0, *) { + for session in installedSessions(source: source, target: target) { + let result = await perform(InstalledPackTranslator(session: session)) + guard !Task.isCancelled else { return nil } + switch result { + case .finished: + return nil + case .needsDownload: + continue + } + } + } + + return TranslationSession.Configuration.replacing( + configuration, + source: source, + target: target + ) + } + + /// Translate language packs, not the Apple Intelligence model list. + private nonisolated static func translatePackLanguages() async -> [Locale.Language] { + if #available(iOS 26.4, *) { + return await LanguageAvailability(preferredStrategy: .lowLatency).supportedLanguages + } + return await LanguageAvailability().supportedLanguages + } + + @available(iOS 26.0, *) + private static func installedSessions( + source: Locale.Language, + target: Locale.Language + ) -> [TranslationSession] { + if #available(iOS 26.4, *) { + return [ + TranslationSession( + installedSource: source, + target: target, + preferredStrategy: .highFidelity + ), + TranslationSession( + installedSource: source, + target: target, + preferredStrategy: .lowLatency + ) + ] + } + return [TranslationSession(installedSource: source, target: target)] + } + + /// `canRequestDownloads` is false, so `translate` throws `notInstalled` + /// instead of presenting the system download UI. + @MainActor + private struct InstalledPackTranslator: MessageTranslating { + let session: TranslationSession + + func translate(_ text: String) async throws -> String { + do { + let response = try await session.translate(text) + return response.targetText + } catch { + throw MessageTranslationNeedsDownloadError.wrapping(error) + } + } + } +} diff --git a/MC1/Services/TranslationSessionRequest.swift b/MC1/Services/TranslationSessionRequest.swift new file mode 100644 index 000000000..ed3b8cb51 --- /dev/null +++ b/MC1/Services/TranslationSessionRequest.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Pending Translation session request owned by the conversation view model. +/// Contains no Translation types so the view model stays Translation-free. +struct TranslationSessionRequest: Equatable, Hashable, Sendable { + let messageID: UUID + let sourceLanguageCode: String + let targetLanguageCode: String + let generation: UInt64 +} diff --git a/MC1/State/AppState+ChatPrefetch.swift b/MC1/State/AppState+ChatPrefetch.swift index c4807e1f7..055c08f83 100644 --- a/MC1/State/AppState+ChatPrefetch.swift +++ b/MC1/State/AppState+ChatPrefetch.swift @@ -94,7 +94,8 @@ extension AppState { isOffline: !offlineMapService.isNetworkAvailable, currentUserName: localNodeName, themeID: themeID, - contentSizeCategory: contentSizeCategory + contentSizeCategory: contentSizeCategory, + preferredLanguageCode: EnvInputs.preferredLanguageCode(from: Locale.current) ) } diff --git a/MC1/Views/Chats/ChatConversationMessagesContent.swift b/MC1/Views/Chats/ChatConversationMessagesContent.swift index 4fad508c1..76dd477fb 100644 --- a/MC1/Views/Chats/ChatConversationMessagesContent.swift +++ b/MC1/Views/Chats/ChatConversationMessagesContent.swift @@ -157,7 +157,10 @@ struct ChatConversationMessagesContent: View { }, snapshotResolver: { MapSnapshotStore.shared.image(for: $0) }, requestSnapshot: { MapSnapshotStore.shared.request($0) }, - retrySnapshot: { MapSnapshotStore.shared.retry($0) } + retrySnapshot: { MapSnapshotStore.shared.retry($0) }, + onTranslationAction: { messageID in + viewModel.performTranslationAction(for: messageID) + } ) ) } diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index 205e1aa95..4198ddcbe 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -1,6 +1,7 @@ import MC1Services import OSLog import SwiftUI +import Translation import UIKit // UIPasteboard for .copy action private let logger = Logger(subsystem: "com.mc1", category: "ChatConversationView") @@ -44,6 +45,9 @@ struct ChatConversationView: View { @State private var blockSenderContext: BlockSenderContext? @State private var sendDMContext: SendDMContext? @State private var imageViewerData: ImageViewerData? + @State private var translationConfiguration: TranslationSession.Configuration? + @State private var systemTranslationText = "" + @State private var showSystemTranslation = false // MARK: - Other State @@ -70,6 +74,7 @@ struct ChatConversationView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.appTheme) private var theme + @Environment(\.locale) private var locale /// Snapshot of env-derived inputs the view model needs to construct /// MessageItems at write time. Recomputed on every render — Equatable @@ -92,7 +97,8 @@ struct ChatConversationView: View { isOffline: !appState.offlineMapService.isNetworkAvailable, currentUserName: appState.localNodeName, themeID: theme.id, - contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize) + contentSizeCategory: AppearanceToken.contentSizeCategoryToken(dynamicTypeSize), + preferredLanguageCode: EnvInputs.preferredLanguageCode(from: locale) ) } @@ -276,11 +282,23 @@ struct ChatConversationView: View { .task(id: appState.servicesVersion) { await performInitialLoad() } + .conversationTranslationSession( + configuration: $translationConfiguration, + request: $chatViewModel.translationSessionRequest, + perform: { translator, request in + await chatViewModel.performPendingTranslation(using: translator, for: request) + } + ) + .translationPresentation( + isPresented: $showSystemTranslation, + text: systemTranslationText + ) .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() + chatViewModel.cancelPendingTranslation() performCleanup() } .onChange(of: chatViewModel.composingText) { _, _ in @@ -615,6 +633,8 @@ struct ChatConversationView: View { handleReply(for: message) case .copy: handleCopy(for: message) + case .translate: + presentSystemTranslation(text: message.text) case .sendAgain: handleSendAgain(for: message) case .blockSender: @@ -655,7 +675,15 @@ struct ChatConversationView: View { } private func handleCopy(for message: MessageDTO) { - UIPasteboard.general.string = message.text + UIPasteboard.general.string = chatViewModel.displayedText(for: message) + } + + private func presentSystemTranslation(text: String) { + systemTranslationText = text + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + showSystemTranslation = true + } } private func handleSendAgain(for message: MessageDTO) { diff --git a/MC1/Views/Chats/Components/BubbleActions.swift b/MC1/Views/Chats/Components/BubbleActions.swift index 0fdc31753..e06bb53e8 100644 --- a/MC1/Views/Chats/Components/BubbleActions.swift +++ b/MC1/Views/Chats/Components/BubbleActions.swift @@ -31,4 +31,5 @@ struct BubbleActions { let snapshotResolver: (MapSnapshotRequest) -> UIImage? let requestSnapshot: (MapSnapshotRequest) -> Void let retrySnapshot: (MapSnapshotRequest) -> Void + let onTranslationAction: (UUID) -> Void } diff --git a/MC1/Views/Chats/Components/BubbleFragmentStack.swift b/MC1/Views/Chats/Components/BubbleFragmentStack.swift index 8845f21f7..d10ff8b0d 100644 --- a/MC1/Views/Chats/Components/BubbleFragmentStack.swift +++ b/MC1/Views/Chats/Components/BubbleFragmentStack.swift @@ -51,6 +51,15 @@ struct BubbleFragmentStack: View, Equatable { if case let .disabled(url) = layout.inlineImage?.state { url } else { nil } } + /// Showing uses the translated string as plain `Text` so mention/hashtag runs are not applied. + private func translationBody(_ textPayload: MessageTextPayload) -> Text { + if case let .showing(translated, _) = textPayload.translation?.phase { + Text(translated) + } else { + Text(textPayload.formatted ?? AttributedString(textPayload.raw)) + } + } + var body: some View { let stack = VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 0) { // Stack alignment carries the footer placement: the time sits at the @@ -60,11 +69,18 @@ struct BubbleFragmentStack: View, Equatable { // self-sizing hosting cell without a resolvable intrinsic width, which // SwiftUI surfaces as a fatal "invalid reuse after initialization failure". VStack(alignment: item.envelope.isOutgoing ? .trailing : .leading, spacing: 2) { + if let chrome = layout.textPayload?.translation { + BubbleTranslationControl( + phase: chrome.phase, + isOutgoing: item.envelope.isOutgoing, + onTap: { callbacks.onTranslationAction?() } + ) + } if let textPayload = layout.textPayload { // Native `Text`: the precomputed `formatted` string already carries every run's color, // underline, bold, and `.link`, so link taps route through the injected `\.openURL` and // Dynamic Type scales for free. `foregroundStyle` colors the raw fallback when unformatted. - Text(textPayload.formatted ?? AttributedString(textPayload.raw)) + translationBody(textPayload) .font(.body) .foregroundStyle(textPayload.baseColor.swiftUIColor) } diff --git a/MC1/Views/Chats/Components/BubbleTranslationControl.swift b/MC1/Views/Chats/Components/BubbleTranslationControl.swift new file mode 100644 index 000000000..92e07bb99 --- /dev/null +++ b/MC1/Views/Chats/Components/BubbleTranslationControl.swift @@ -0,0 +1,58 @@ +import MC1Services +import SwiftUI + +/// In-bubble Translation offer. Not a `Button`, so `tapYieldingToLongPress` can +/// yield to the bubble long-press. VoiceOver-hidden; the bubble exposes the action. +struct BubbleTranslationControl: View { + let phase: MessageTranslationChrome.Phase + let isOutgoing: Bool + let onTap: () -> Void + + @Environment(\.appTheme) private var theme + + var body: some View { + label + .font(.caption) + .imageScale(.small) + .labelStyle(.titleAndIcon) + .foregroundStyle(foreground) + .tint(isOutgoing ? theme.outgoingTextColor : .accentColor) + .multilineTextAlignment(isOutgoing ? .trailing : .leading) + .contentShape(.rect) + .accessibilityHidden(true) + .tapYieldingToLongPress { + if case .inProgress = phase { return } + onTap() + } + } + + static func title(for phase: MessageTranslationChrome.Phase) -> String { + switch phase { + case .offer: + L10n.Chats.Chats.Message.Action.translate + case .inProgress: + L10n.Chats.Chats.Message.Action.translating + case .showing: + L10n.Chats.Chats.Message.Action.showOriginal + } + } + + private var foreground: Color { + isOutgoing ? theme.outgoingTextColor : .accentColor + } + + @ViewBuilder + private var label: some View { + switch phase { + case .offer, .showing: + Label(Self.title(for: phase), systemImage: "translate") + case .inProgress: + Label { + Text(Self.title(for: phase)) + } icon: { + ProgressView() + .controlSize(.mini) + } + } + } +} diff --git a/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift b/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift new file mode 100644 index 000000000..154909c26 --- /dev/null +++ b/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift @@ -0,0 +1,71 @@ +import SwiftUI +@preconcurrency import Translation + +/// Shared Chat/Room `.translationTask` wiring. `.task(id:)` cancels launch when +/// the request changes; `.translationTask` performs `sessionBoundRequest`. +struct ConversationTranslationSessionModifier: ViewModifier { + @Binding var configuration: TranslationSession.Configuration? + @Binding var request: TranslationSessionRequest? + let perform: @MainActor (any MessageTranslating, TranslationSessionRequest) async -> TranslationPerformResult + + /// Request that produced the current `configuration`. Assigned in the same + /// turn as `configuration`. + @State private var sessionBoundRequest: TranslationSessionRequest? + + func body(content: Content) -> some View { + content + .translationTask(configuration) { session in + guard let sessionBoundRequest, + request?.generation == sessionBoundRequest.generation else { return } + _ = await perform( + TranslationSessionMessageTranslator(session: session), + sessionBoundRequest + ) + } + .task(id: request) { + guard let launchRequest = request else { + configuration = nil + sessionBoundRequest = nil + return + } + let result = await TranslationSessionLauncher.launch( + request: launchRequest, + replacing: configuration + ) { translator in + await perform(translator, launchRequest) + } + guard !Task.isCancelled, + self.request?.generation == launchRequest.generation else { return } + configuration = result + sessionBoundRequest = result == nil ? nil : launchRequest + } + } +} + +extension View { + func conversationTranslationSession( + configuration: Binding, + request: Binding, + perform: @escaping @MainActor (any MessageTranslating, TranslationSessionRequest) async -> TranslationPerformResult + ) -> some View { + modifier( + ConversationTranslationSessionModifier( + configuration: configuration, + request: request, + perform: perform + ) + ) + } +} + +extension ConversationTranslationSessionModifier { + @MainActor + private struct TranslationSessionMessageTranslator: MessageTranslating { + let session: TranslationSession + + func translate(_ text: String) async throws -> String { + let response = try await session.translate(text) + return response.targetText + } + } +} diff --git a/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift b/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift index cc472f39e..c3644a633 100644 --- a/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift +++ b/MC1/Views/Chats/Components/MessageBubbleCallbacks.swift @@ -19,4 +19,5 @@ struct MessageBubbleCallbacks { var snapshotResolver: ((MapSnapshotRequest) -> UIImage?)? var requestSnapshot: ((MapSnapshotRequest) -> Void)? var retrySnapshot: ((MapSnapshotRequest) -> Void)? + var onTranslationAction: (() -> Void)? } diff --git a/MC1/Views/Chats/Components/MessageBubbleView.swift b/MC1/Views/Chats/Components/MessageBubbleView.swift index bfe5e0ef8..5344f4f65 100644 --- a/MC1/Views/Chats/Components/MessageBubbleView.swift +++ b/MC1/Views/Chats/Components/MessageBubbleView.swift @@ -40,7 +40,8 @@ struct MessageBubbleView: View, Equatable { onMapPreviewTap: { coordinate in actions.onMapPreviewTap(coordinate) }, snapshotResolver: actions.snapshotResolver, requestSnapshot: actions.requestSnapshot, - retrySnapshot: actions.retrySnapshot + retrySnapshot: actions.retrySnapshot, + onTranslationAction: { actions.onTranslationAction(message.id) } ) ) } else { diff --git a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift index f721b5f07..6fda41921 100644 --- a/MC1/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/MC1/Views/Chats/Components/UnifiedMessageBubble.swift @@ -113,6 +113,20 @@ struct UnifiedMessageBubble: View, Equatable { callbacks.onLongPress?() } .accessibilityActions { + if let chrome = layout.textPayload?.translation { + switch chrome.phase { + case .offer: + Button(L10n.Chats.Chats.Message.Action.translate) { + callbacks.onTranslationAction?() + } + case .showing: + Button(L10n.Chats.Chats.Message.Action.showOriginal) { + callbacks.onTranslationAction?() + } + case .inProgress: + EmptyView() + } + } if item.footer.showStatusRow, item.footer.status == .failed, let onRetry = callbacks.onRetry { @@ -336,7 +350,14 @@ struct UnifiedMessageBubble: View, Equatable { } } } - label += message.text + switch layout.textPayload?.translation?.phase { + case let .showing(translated, _): + label += L10n.Chats.Chats.Message.translatedAccessibility(translated) + case .inProgress: + label += "\(message.text), \(L10n.Chats.Chats.Message.Action.translating)" + default: + label += message.text + } if item.envelope.isOutgoing { label += ", \(MessageStatusText.text(for: item.footer))" } diff --git a/MC1/Views/Chats/Reactions/MessageAction.swift b/MC1/Views/Chats/Reactions/MessageAction.swift index ff25e5ac1..1d1b800e8 100644 --- a/MC1/Views/Chats/Reactions/MessageAction.swift +++ b/MC1/Views/Chats/Reactions/MessageAction.swift @@ -4,6 +4,7 @@ enum MessageAction: Equatable { case react(String) case reply case copy + case translate case sendAgain case sendDM case blockSender diff --git a/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift b/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift index c27e86a1a..3f26628ec 100644 --- a/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift +++ b/MC1/Views/Chats/Reactions/Sections/ActionsButtonsSection.swift @@ -32,6 +32,12 @@ struct ActionsButtonsSection: View { action: { onSelectAction(.copy) } ) + ActionButton( + title: L10n.Chats.Chats.Message.Action.translate, + icon: "translate", + action: { onSelectAction(.translate) } + ) + if availability.canSendAgain { ActionButton( title: L10n.Chats.Chats.Message.Action.sendAgain, diff --git a/MC1/Views/Chats/Timeline/ChatTimeline+BakeUpdates.swift b/MC1/Views/Chats/Timeline/ChatTimeline+BakeUpdates.swift index 5a90787da..a02e53fe8 100644 --- a/MC1/Views/Chats/Timeline/ChatTimeline+BakeUpdates.swift +++ b/MC1/Views/Chats/Timeline/ChatTimeline+BakeUpdates.swift @@ -95,6 +95,9 @@ extension ChatTimeline { bake.decodedPreviewAssets.removeAll() bake.legacyPreviewDecodeInFlight.removeAll() bake.cachedURLs.removeAll() + bake.detectedLanguages.removeAll() + bake.translationPhases.removeAll() + bake.translationCache.removeAll() bake.imageURLsServingPages.removeAll() bake.mapPreviewRequestIndex.removeAll() bake.loadedImageData.removeAllObjects() @@ -107,6 +110,9 @@ extension ChatTimeline { /// a row that no longer exists. func removeBakeState(for messageID: UUID) { bake.previewStates.removeValue(forKey: messageID) + bake.detectedLanguages.removeValue(forKey: messageID) + bake.translationPhases.removeValue(forKey: messageID) + bake.translationCache.removeValue(forKey: messageID) bake.loadedPreviews.removeValue(forKey: messageID) bake.decodedPreviewAssets.removeValue(forKey: messageID) bake.loadedImageData.removeObject(forKey: messageID as NSUUID) diff --git a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift index 7c0eb6103..54f5c08c6 100644 --- a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift @@ -113,6 +113,7 @@ extension ChatMessageBakeState { senderTables: ChatSenderTables ) -> MessageBuildInputs { seedPreviewStateIfNeeded(for: message, envInputs: envInputs) + seedDetectedLanguageIfNeeded(for: message) let flags = Self.computeDisplayFlags(for: message, previous: previous, next: next) let cachedURL = cachedURLs[message.id].flatMap(\.self) // Extension-based image classification, minus URLs the fetch path has @@ -208,10 +209,22 @@ extension ChatMessageBakeState { showSenderName: flags.showSenderName, showNewMessagesDivider: message.id == newMessagesDividerMessageID, showDayDivider: flags.showDayDivider, - incomingAvatar: incomingAvatar + incomingAvatar: incomingAvatar, + translation: MessageTranslationChrome.resolved( + detected: detectedLanguages[message.id], + phase: translationPhases[message.id], + preferredLanguageCode: envInputs.preferredLanguageCode + ) ) } + /// Writes a missing detection key. Stores `.undetermined` so a locale + /// change does not re-run the recognizer. + func seedDetectedLanguageIfNeeded(for message: MessageDTO) { + guard detectedLanguages[message.id] == nil else { return } + detectedLanguages[message.id] = MessageLanguageDetector.dominantLanguage(for: message.text) + } + /// 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 diff --git a/MC1/Views/Chats/ViewModel/ChatMessageBakeState.swift b/MC1/Views/Chats/ViewModel/ChatMessageBakeState.swift index 64c55ca7a..95fed3e14 100644 --- a/MC1/Views/Chats/ViewModel/ChatMessageBakeState.swift +++ b/MC1/Views/Chats/ViewModel/ChatMessageBakeState.swift @@ -49,6 +49,18 @@ final class ChatMessageBakeState { /// Cached URL detection results to avoid re-running NSDataDetector on rebuilds var cachedURLs: [UUID: URL?] = [:] + /// Detected language keyed by message id. Missing key = not yet run; + /// `.undetermined` = ran, no code. + var detectedLanguages: [UUID: DetectedLanguage] = [:] + + /// Per-message Translation offer phase. Missing means bake decides from + /// detection (typically `.offer` or no row). + var translationPhases: [UUID: MessageTranslationChrome.Phase] = [:] + + /// Last successful translation per message, keyed by collapsed target + /// language code. A DE→EN result must not be reused as DE→FR. + var translationCache: [UUID: [String: String]] = [:] + /// 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 diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift new file mode 100644 index 000000000..82011fc1b --- /dev/null +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift @@ -0,0 +1,160 @@ +import Foundation +import MC1Services + +extension ChatViewModel { + /// In-bubble Translation action. Show original and cache hits skip a session. + /// One in-flight translation per conversation. + func performTranslationAction(for messageID: UUID) { + if case .showing = bake.translationPhases[messageID] { + bake.translationPhases[messageID] = .offer + timeline.rebakeRow(messageID) + return + } + let targetLanguageCode = MessageLanguageDetector.collapsedLanguageCode( + envInputs.preferredLanguageCode + ) + if let cached = bake.translationCache[messageID]?[targetLanguageCode] { + resetInProgressTranslations() + translationSessionRequest = nil + bake.translationPhases[messageID] = .showing( + translatedText: cached, + targetLanguageCode: targetLanguageCode + ) + timeline.rebakeRow(messageID) + return + } + if case .inProgress = bake.translationPhases[messageID] { + return + } + resetInProgressTranslations() + guard let sourceLanguageCode = bake.detectedLanguages[messageID]?.code else { + return + } + bake.translationPhases[messageID] = .inProgress + timeline.rebakeRow(messageID) + translationGeneration += 1 + translationSessionRequest = TranslationSessionRequest( + messageID: messageID, + sourceLanguageCode: sourceLanguageCode, + targetLanguageCode: targetLanguageCode, + generation: translationGeneration + ) + } + + /// Apply `translator` only while `request.generation` is still current. + /// Never writes `MessageDTO.text`. + @discardableResult + func performPendingTranslation( + using translator: any MessageTranslating, + for request: TranslationSessionRequest + ) async -> TranslationPerformResult { + guard isCurrent(request) else { return .finished } + let capturedID = request.messageID + let targetLanguageCode = MessageLanguageDetector.collapsedLanguageCode( + request.targetLanguageCode + ) + guard let text = messagesByID[capturedID]?.text else { + guard isCurrent(request) else { return .finished } + bake.translationPhases[capturedID] = .offer + translationSessionRequest = nil + timeline.rebakeRow(capturedID) + return .finished + } + + do { + let result = try await translator.translate(text) + guard isCurrent(request) else { return .finished } + var perTarget = bake.translationCache[capturedID] ?? [:] + perTarget[targetLanguageCode] = result + bake.translationCache[capturedID] = perTarget + bake.translationPhases[capturedID] = .showing( + translatedText: result, + targetLanguageCode: targetLanguageCode + ) + translationSessionRequest = nil + timeline.rebakeRow(capturedID) + return .finished + } catch is MessageTranslationNeedsDownloadError { + guard isCurrent(request) else { return .finished } + return .needsDownload + } catch { + guard isCurrent(request) else { return .finished } + bake.translationPhases[capturedID] = .offer + if !isQuietTranslationCancel(error) { + errorMessage = error.userFacingMessage + } + translationSessionRequest = nil + timeline.rebakeRow(capturedID) + return .finished + } + } + + /// Returns `.inProgress` rows to the Translation offer and rebakes them. + /// Leaves `.showing` and the translation cache for a surviving view model. + func cancelPendingTranslation() { + translationGeneration += 1 + translationSessionRequest = nil + let idsToReset = bake.translationPhases.compactMap { id, phase -> UUID? in + if case .inProgress = phase { id } else { nil } + } + for id in idsToReset { + bake.translationPhases[id] = .offer + timeline.rebakeRow(id) + } + } + + /// Drops in-flight work and showing chrome whose target is not `preferredLanguageCode`. + func invalidateTranslations(preferredLanguageCode: String) { + translationGeneration += 1 + translationSessionRequest = nil + let preferred = MessageLanguageDetector.collapsedLanguageCode(preferredLanguageCode) + for (id, phase) in bake.translationPhases { + switch phase { + case .inProgress: + bake.translationPhases[id] = .offer + case let .showing(_, target) where !MessageLanguageDetector.isSameLanguage(target, preferred): + bake.translationPhases[id] = .offer + case .offer, .showing: + break + } + } + } + + /// Clipboard-only. Reads the same chrome the bubble draws. Reply, Send Again, + /// reaction hash, and preview stay on stored `message.text`. + func displayedText(for message: MessageDTO) -> String { + if case let .showing(translatedText, _) = translation(for: message.id)?.phase { + return translatedText + } + return message.text + } + + func translation(for messageID: UUID) -> MessageTranslationChrome? { + items.first { $0.id == messageID }?.translation + } + + /// Resets every in-flight row back to the Translation offer and bumps + /// generation so an in-flight result is discarded. + private func resetInProgressTranslations() { + let inProgressIDs = bake.translationPhases.compactMap { id, phase -> UUID? in + if case .inProgress = phase { id } else { nil } + } + guard !inProgressIDs.isEmpty else { return } + translationGeneration += 1 + for id in inProgressIDs { + bake.translationPhases[id] = .offer + timeline.rebakeRow(id) + } + } + + private func isCurrent(_ request: TranslationSessionRequest) -> Bool { + translationSessionRequest?.generation == request.generation + } + + /// `CancellationError`, or `CocoaError.userCancelled` bridged as NSError. + private func isQuietTranslationCancel(_ error: Error) -> Bool { + if error is CancellationError { return true } + let nsError = error as NSError + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + } +} diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel.swift b/MC1/Views/Chats/ViewModel/ChatViewModel.swift index 59d9b5f5d..53e53fed3 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel.swift @@ -145,6 +145,12 @@ final class ChatViewModel { /// 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) { + if !MessageLanguageDetector.isSameLanguage( + envInputs.preferredLanguageCode, + new.preferredLanguageCode + ) { + invalidateTranslations(preferredLanguageCode: new.preferredLanguageCode) + } timeline.applyEnvInputs(new) } @@ -196,6 +202,14 @@ final class ChatViewModel { /// Error message if any var errorMessage: String? + /// Pending Translation session request. Observed by the conversation view + /// so it can invalidate `TranslationSession.Configuration`. + var translationSessionRequest: TranslationSessionRequest? + + /// Monotonic generation for in-flight translation. Apply a result only + /// when it still matches `translationSessionRequest.generation`. + @ObservationIgnored var translationGeneration: UInt64 = 0 + /// 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 diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index 623231988..36b05a27f 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -1,5 +1,6 @@ import MC1Services import SwiftUI +import Translation import UIKit /// Full room chat interface @@ -8,6 +9,7 @@ struct RoomConversationView: View { @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var scenePhase @Environment(\.appTheme) private var theme + @Environment(\.locale) private var locale @State private var session: RemoteNodeSessionDTO @State private var viewModel = RoomConversationViewModel() @@ -20,6 +22,9 @@ struct RoomConversationView: View { @State private var isAtBottom = true @State private var unreadCount = 0 @State private var scrollToBottomRequest = 0 + @State private var translationConfiguration: TranslationSession.Configuration? + @State private var systemTranslationText = "" + @State private var showSystemTranslation = false @AppStorage(AppStorageKey.replyWithQuote.rawValue) private var replyWithQuote = AppStorageKey.defaultReplyWithQuote @@ -81,7 +86,10 @@ struct RoomConversationView: View { .sheet(item: $selectedRoomMessage) { message in RoomMessageActionsSheet( message: message, - availability: RoomMessageActionAvailability(message: message, session: session), + availability: RoomMessageActionAvailability( + message: message, + session: session + ), onAction: { dispatch($0, for: message) } ) } @@ -125,8 +133,24 @@ struct RoomConversationView: View { conversation: nil ) await chatViewModel.loadAllContacts(radioID: session.radioID) + viewModel.applyPreferredLanguageCode(EnvInputs.preferredLanguageCode(from: locale)) await viewModel.loadMessages(for: session) } + .conversationTranslationSession( + configuration: $translationConfiguration, + request: $viewModel.translationSessionRequest, + perform: { translator, request in + await viewModel.performPendingTranslation(using: translator, for: request) + } + ) + .translationPresentation( + isPresented: $showSystemTranslation, + text: systemTranslationText + ) + .onChange(of: locale) { _, newLocale in + viewModel.applyPreferredLanguageCode(EnvInputs.preferredLanguageCode(from: newLocale)) + } + .errorAlert($viewModel.errorMessage) .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. @@ -183,6 +207,7 @@ struct RoomConversationView: View { } } .onDisappear { + viewModel.cancelPendingTranslation() // 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 { @@ -209,7 +234,7 @@ struct RoomConversationView: View { MessagesView( viewModel: viewModel, hasLoadedOnce: viewModel.hasLoadedOnce, - messages: viewModel.messages, + tiledRows: viewModel.tiledRows, isAtBottom: $isAtBottom, unreadCount: $unreadCount, scrollToBottomRequest: scrollToBottomRequest, @@ -218,7 +243,8 @@ struct RoomConversationView: View { onRetry: { id in Task { await viewModel.retryMessage(id: id) } }, - onLongPress: { selectedRoomMessage = $0 } + onLongPress: { selectedRoomMessage = $0 }, + onTranslationAction: { viewModel.performTranslationAction(for: $0) } ) } @@ -264,7 +290,9 @@ extension RoomConversationView { private func dispatch(_ action: RoomMessageAction, for message: RoomMessageDTO) { switch action { case .copy: - UIPasteboard.general.string = message.text + UIPasteboard.general.string = viewModel.displayedText(for: message) + case .translate: + presentSystemTranslation(text: message.text) case .reply: handleReply(for: message) case .sendDM: @@ -274,6 +302,14 @@ extension RoomConversationView { } } + private func presentSystemTranslation(text: String) { + systemTranslationText = text + Task { + try? await Task.sleep(for: MessageActionsPresentation.dismissalDelay) + showSystemTranslation = true + } + } + private func handleReply(for message: RoomMessageDTO) { if replyWithQuote { viewModel.composingText = MentionUtilities.buildReplyText( @@ -310,7 +346,7 @@ extension RoomConversationView { private struct MessagesView: View { var viewModel: RoomConversationViewModel let hasLoadedOnce: Bool - let messages: [RoomMessageDTO] + let tiledRows: [RoomTiledRow] @Binding var isAtBottom: Bool @Binding var unreadCount: Int let scrollToBottomRequest: Int @@ -318,6 +354,7 @@ private struct MessagesView: View { let theme: Theme let onRetry: (UUID) -> Void let onLongPress: (RoomMessageDTO) -> Void + let onTranslationAction: (UUID) -> Void @Environment(\.openURL) private var openURL @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -328,22 +365,16 @@ private struct MessagesView: View { if !hasLoadedOnce { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if messages.isEmpty { + } else if tiledRows.isEmpty { EmptyMessagesView(session: session) } else { - let rows = RoomConversationViewModel.tiledRows(in: messages) ChatTiledView( - items: rows, + items: tiledRows, cellContent: { row in - messageBubble( - for: row.message, - showTimestamp: row.showTimestamp, - showSenderName: row.showSenderName, - showAvatar: row.showAvatar - ) - .environment(\.appTheme, theme) - .environment(\.openURL, openURL) - .environment(\.incomingAvatarFlight, incomingAvatarFlight) + messageBubble(for: row) + .environment(\.appTheme, theme) + .environment(\.openURL, openURL) + .environment(\.incomingAvatarFlight, incomingAvatarFlight) }, contentBackground: theme.surfaces?.canvas, isAtBottom: $isAtBottom, @@ -370,21 +401,18 @@ private struct MessagesView: View { .themedCanvas(theme) } - private func messageBubble( - for message: RoomMessageDTO, - showTimestamp: Bool, - showSenderName: Bool, - showAvatar: Bool - ) -> some View { + private func messageBubble(for row: RoomTiledRow) -> some View { RoomMessageBubble( - message: message, - showTimestamp: showTimestamp, - showSenderName: showSenderName, - showAvatar: showAvatar, - onRetry: message.status == .failed ? { - onRetry(message.id) + message: row.message, + showTimestamp: row.showTimestamp, + showSenderName: row.showSenderName, + showAvatar: row.showAvatar, + translation: row.translation, + onRetry: row.message.status == .failed ? { + onRetry(row.message.id) } : nil, - onLongPress: onLongPress + onLongPress: onLongPress, + onTranslationAction: { onTranslationAction(row.message.id) } ) } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift new file mode 100644 index 000000000..96f304c44 --- /dev/null +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift @@ -0,0 +1,176 @@ +import Foundation +import MC1Services + +extension RoomConversationViewModel { + func applyPreferredLanguageCode(_ code: String) { + guard !MessageLanguageDetector.isSameLanguage(preferredLanguageCode, code) else { return } + invalidateTranslations(preferredLanguageCode: code) + preferredLanguageCode = code + refreshTiledRows() + } + + func performTranslationAction(for messageID: UUID) { + if case .showing = translationPhases[messageID] { + translationPhases[messageID] = .offer + refreshTiledRows() + return + } + let targetLanguageCode = MessageLanguageDetector.collapsedLanguageCode(preferredLanguageCode) + if let cached = translationCache[messageID]?[targetLanguageCode] { + resetInProgressTranslations() + translationSessionRequest = nil + translationPhases[messageID] = .showing( + translatedText: cached, + targetLanguageCode: targetLanguageCode + ) + refreshTiledRows() + return + } + if case .inProgress = translationPhases[messageID] { + return + } + resetInProgressTranslations() + guard let sourceLanguageCode = detectedLanguages[messageID]?.code else { + return + } + translationPhases[messageID] = .inProgress + refreshTiledRows() + translationGeneration += 1 + translationSessionRequest = TranslationSessionRequest( + messageID: messageID, + sourceLanguageCode: sourceLanguageCode, + targetLanguageCode: targetLanguageCode, + generation: translationGeneration + ) + } + + @discardableResult + func performPendingTranslation( + using translator: any MessageTranslating, + for request: TranslationSessionRequest + ) async -> TranslationPerformResult { + guard isCurrent(request) else { return .finished } + let capturedID = request.messageID + let targetLanguageCode = MessageLanguageDetector.collapsedLanguageCode( + request.targetLanguageCode + ) + guard let text = messages.first(where: { $0.id == capturedID })?.text else { + guard isCurrent(request) else { return .finished } + translationPhases[capturedID] = .offer + translationSessionRequest = nil + refreshTiledRows() + return .finished + } + + do { + let result = try await translator.translate(text) + guard isCurrent(request) else { return .finished } + var perTarget = translationCache[capturedID] ?? [:] + perTarget[targetLanguageCode] = result + translationCache[capturedID] = perTarget + translationPhases[capturedID] = .showing( + translatedText: result, + targetLanguageCode: targetLanguageCode + ) + translationSessionRequest = nil + refreshTiledRows() + return .finished + } catch is MessageTranslationNeedsDownloadError { + guard isCurrent(request) else { return .finished } + return .needsDownload + } catch { + guard isCurrent(request) else { return .finished } + translationPhases[capturedID] = .offer + if !isQuietTranslationCancel(error) { + errorMessage = error.userFacingMessage + } + translationSessionRequest = nil + refreshTiledRows() + return .finished + } + } + + func cancelPendingTranslation() { + translationGeneration += 1 + translationSessionRequest = nil + let idsToReset = translationPhases.compactMap { id, phase -> UUID? in + if case .inProgress = phase { id } else { nil } + } + for id in idsToReset { + translationPhases[id] = .offer + } + refreshTiledRows() + } + + func invalidateTranslations(preferredLanguageCode: String) { + translationGeneration += 1 + translationSessionRequest = nil + let preferred = MessageLanguageDetector.collapsedLanguageCode(preferredLanguageCode) + for (id, phase) in translationPhases { + switch phase { + case .inProgress: + translationPhases[id] = .offer + case let .showing(_, target) where !MessageLanguageDetector.isSameLanguage(target, preferred): + translationPhases[id] = .offer + case .offer, .showing: + break + } + } + } + + func displayedText(for message: RoomMessageDTO) -> String { + if case let .showing(translatedText, _) = translation(for: message.id)?.phase { + return translatedText + } + return message.text + } + + func translation(for messageID: UUID) -> MessageTranslationChrome? { + tiledRows.first { $0.id == messageID }?.translation + } + + func refreshTiledRows() { + seedDetectedLanguages() + var translations: [UUID: MessageTranslationChrome] = [:] + for message in messages { + if let chrome = MessageTranslationChrome.resolved( + detected: detectedLanguages[message.id], + phase: translationPhases[message.id], + preferredLanguageCode: preferredLanguageCode + ) { + translations[message.id] = chrome + } + } + tiledRows = Self.tiledRows(in: messages, translations: translations) + } + + private func seedDetectedLanguages() { + for message in messages { + guard detectedLanguages[message.id] == nil else { continue } + detectedLanguages[message.id] = MessageLanguageDetector.dominantLanguage(for: message.text) + } + } + + private func resetInProgressTranslations() { + let inProgressIDs = translationPhases.compactMap { id, phase -> UUID? in + if case .inProgress = phase { id } else { nil } + } + guard !inProgressIDs.isEmpty else { return } + translationGeneration += 1 + for id in inProgressIDs { + translationPhases[id] = .offer + } + refreshTiledRows() + } + + private func isCurrent(_ request: TranslationSessionRequest) -> Bool { + translationSessionRequest?.generation == request.generation + } + + /// `CancellationError`, or `CocoaError.userCancelled` bridged as NSError. + private func isQuietTranslationCancel(_ error: Error) -> Bool { + if error is CancellationError { return true } + let nsError = error as NSError + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSUserCancelledError + } +} diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift index 60416b6dd..19ce848fa 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel.swift @@ -16,6 +16,30 @@ final class RoomConversationViewModel { /// Room messages var messages: [RoomMessageDTO] = [] + /// Materialized tiled rows. Chrome including Translation offer lives here + /// so `ChatTiledView` reconfigures when a phase changes. + var tiledRows: [RoomTiledRow] = [] + + /// Pending Translation session request. Observed so the view can invalidate + /// `TranslationSession.Configuration`. + var translationSessionRequest: TranslationSessionRequest? + + var preferredLanguageCode: String = EnvInputs.defaultPreferredLanguageCode + + /// Monotonic generation for in-flight translation. Apply a result only + /// when it still matches `translationSessionRequest.generation`. + @ObservationIgnored var translationGeneration: UInt64 = 0 + + /// Detected language keyed by message id. Missing key = not yet run; + /// `.undetermined` = ran, no code. + var detectedLanguages: [UUID: DetectedLanguage] = [:] + /// Per-message Translation offer phase. Missing lets + /// `MessageTranslationChrome.resolved` decide from detection. + var translationPhases: [UUID: MessageTranslationChrome.Phase] = [:] + /// Last successful translation per message, keyed by collapsed target + /// language code. A DE→EN result must not be reused as DE→FR. + var translationCache: [UUID: [String: String]] = [:] + /// Loading state var isLoading = false @@ -98,6 +122,7 @@ final class RoomConversationViewModel { do { messages = try await roomServerService.fetchMessages(sessionID: session.id) + refreshTiledRows() // Clear unread count, remove any delivered notifications for this // room still in the tray, and update the badge @@ -125,6 +150,7 @@ final class RoomConversationViewModel { let index = messages.firstIndex { $0.timestamp > message.timestamp } ?? messages.endIndex let isTailAppend = index == messages.endIndex messages.insert(message, at: index) + refreshTiledRows() if isTailAppend, let previous = previousTail, Self.incomingClusterContinues(from: previous, to: message) { @@ -232,6 +258,7 @@ final class RoomConversationViewModel { // Update local array if let index = messages.firstIndex(where: { $0.id == id }) { messages[index] = updatedMessage + refreshTiledRows() } } catch { errorMessage = error.userFacingMessage @@ -280,14 +307,18 @@ final class RoomConversationViewModel { return (nameIDs, avatarIDs) } - static func tiledRows(in messages: [RoomMessageDTO]) -> [RoomTiledRow] { + static func tiledRows( + in messages: [RoomMessageDTO], + translations: [UUID: MessageTranslationChrome] = [:] + ) -> [RoomTiledRow] { let bookends = incomingBookends(in: messages) return messages.enumerated().map { index, message in RoomTiledRow( message: message, showTimestamp: shouldShowTimestamp(at: index, in: messages), showSenderName: bookends.nameIDs.contains(message.id), - showAvatar: bookends.avatarIDs.contains(message.id) + showAvatar: bookends.avatarIDs.contains(message.id), + translation: translations[message.id] ) } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift index 228af92b1..7763db34f 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageAction.swift @@ -1,6 +1,7 @@ /// An action a user can take on a room message from its long-press sheet. enum RoomMessageAction { case copy + case translate case reply case sendDM case sendAgain diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift index 7d884bea3..b03840fdd 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionAvailability.swift @@ -8,7 +8,10 @@ struct RoomMessageActionAvailability { let canSendDM: Bool let canSendAgain: Bool - init(message: RoomMessageDTO, session: RemoteNodeSessionDTO) { + 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 76eff048e..1cb1a9de7 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageActionsSheet.swift @@ -86,6 +86,12 @@ struct RoomMessageActionsSheet: View { action: { performAction(.copy) } ) + ActionButton( + title: L10n.Chats.Chats.Message.Action.translate, + icon: "translate", + action: { performAction(.translate) } + ) + if availability.canSendAgain { ActionButton( title: L10n.Chats.Chats.Message.Action.sendAgain, diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift index 0fbf172d0..29650d95d 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift @@ -7,8 +7,10 @@ struct RoomMessageBubble: View { let showTimestamp: Bool let showSenderName: Bool let showAvatar: Bool + var translation: MessageTranslationChrome? var onRetry: (() -> Void)? var onLongPress: ((RoomMessageDTO) -> Void)? + var onTranslationAction: (() -> Void)? @Environment(\.colorSchemeContrast) private var colorSchemeContrast @Environment(\.appTheme) private var theme @@ -43,6 +45,20 @@ struct RoomMessageBubble: View { onLongPress?(message) } .accessibilityActions { + if let chrome = translation { + switch chrome.phase { + case .offer: + Button(L10n.Chats.Chats.Message.Action.translate) { + onTranslationAction?() + } + case .showing: + Button(L10n.Chats.Chats.Message.Action.showOriginal) { + onTranslationAction?() + } + case .inProgress: + EmptyView() + } + } if accessibilityShowsRetryAction { Button(L10n.Chats.Chats.Message.Action.retry) { performAccessibilityRetry() } } @@ -66,10 +82,18 @@ struct RoomMessageBubble: View { } var accessibilityMessageLabel: String { + let body: String = switch translation?.phase { + case let .showing(translated, _): + L10n.Chats.Chats.Message.translatedAccessibility(translated) + case .inProgress: + "\(message.text), \(L10n.Chats.Chats.Message.Action.translating)" + default: + message.text + } if isFromSelf { - return "\(message.text), \(message.accessibilityStatusLabel)" + return "\(body), \(message.accessibilityStatusLabel)" } - return "\(message.authorDisplayName): \(message.text)" + return "\(message.authorDisplayName): \(body)" } var accessibilityShowsRetryAction: Bool { @@ -107,7 +131,9 @@ struct RoomMessageBubble: View { showSenderName: showSenderName, showAvatar: showAvatar, highContrast: colorSchemeContrast == .increased, - formattedBodyText: formattedBodyText + formattedBodyText: formattedBodyText, + translation: translation, + onTranslationAction: onTranslationAction ) .messageBubbleLongPressGesture( isPressing: $isLongPressing, @@ -150,6 +176,8 @@ private struct BubbleContent: View { let showAvatar: Bool let highContrast: Bool let formattedBodyText: AttributedString + var translation: MessageTranslationChrome? + var onTranslationAction: (() -> Void)? @Environment(\.appTheme) private var theme @Environment(\.colorScheme) private var colorScheme @@ -195,11 +223,31 @@ private struct BubbleContent: View { } private var messageBox: some View { - MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf, precomputedText: formattedBodyText) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(bubbleBackground) - .clipShape(.rect(cornerRadius: 16, style: .continuous)) + VStack(alignment: isFromSelf ? .trailing : .leading, spacing: 2) { + if let chrome = translation { + BubbleTranslationControl( + phase: chrome.phase, + isOutgoing: isFromSelf, + onTap: { onTranslationAction?() } + ) + } + messageBody + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(bubbleBackground) + .clipShape(.rect(cornerRadius: 16, style: .continuous)) + } + + @ViewBuilder + private var messageBody: some View { + if case let .showing(translated, _) = translation?.phase { + Text(translated) + .font(.body) + .foregroundStyle(textColor) + } else { + MessageText(message.text, baseColor: textColor, isOutgoing: isFromSelf, precomputedText: formattedBodyText) + } } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift b/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift index 3388af6b0..2bd5d2447 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomTiledRow.swift @@ -8,6 +8,7 @@ struct RoomTiledRow: Identifiable, Hashable, Sendable { let showTimestamp: Bool let showSenderName: Bool let showAvatar: Bool + let translation: MessageTranslationChrome? var id: UUID { message.id diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/DetectedLanguage.swift b/MC1Services/Sources/MC1Services/Models/Rendering/DetectedLanguage.swift new file mode 100644 index 000000000..a7b874956 --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/Rendering/DetectedLanguage.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Result of running language detection for one message. +/// Missing dictionary key means detection has not run yet. +public enum DetectedLanguage: Sendable, Equatable { + case undetermined + case identified(languageCode: String) + + public var code: String? { + switch self { + case .undetermined: nil + case let .identified(languageCode): languageCode + } + } +} diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift index ed5f816e2..99414fd96 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/EnvInputs.swift @@ -33,6 +33,9 @@ public struct EnvInputs: Sendable, Hashable { /// `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 + /// App-locale language code (`"en"`, `"de"`, `"zh"`), never a region qualifier. + /// A change forces a full `buildItems()` so Translation chrome re-evaluates. + public let preferredLanguageCode: 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 @@ -55,7 +58,8 @@ public struct EnvInputs: Sendable, Hashable { isOffline: Bool, currentUserName: String, themeID: String, - contentSizeCategory: String + contentSizeCategory: String, + preferredLanguageCode: String ) { self.autoPlayGIFs = autoPlayGIFs self.showIncomingPath = showIncomingPath @@ -70,6 +74,7 @@ public struct EnvInputs: Sendable, Hashable { self.currentUserName = currentUserName self.themeID = themeID self.contentSizeCategory = contentSizeCategory + self.preferredLanguageCode = preferredLanguageCode } /// Identifier of the built-in default theme. Shared so `EnvInputs.default` and `Theme.default.id` @@ -81,6 +86,14 @@ public struct EnvInputs: Sendable, Hashable { /// cannot drift apart. public static let defaultContentSizeCategory = "large" + /// Fallback language code when `Locale.Language.languageCode` is missing. + public static let defaultPreferredLanguageCode = "en" + + /// App-locale language subtag used as the Translation target (`en-US` → `en`). + public static func preferredLanguageCode(from locale: Locale) -> String { + locale.language.languageCode?.identifier ?? defaultPreferredLanguageCode + } + public static let `default` = EnvInputs( autoPlayGIFs: AppStorageKey.defaultAutoPlayGIFs, showIncomingPath: AppStorageKey.defaultShowIncomingPath, @@ -94,6 +107,7 @@ public struct EnvInputs: Sendable, Hashable { isOffline: false, currentUserName: "", themeID: defaultThemeID, - contentSizeCategory: defaultContentSizeCategory + contentSizeCategory: defaultContentSizeCategory, + preferredLanguageCode: defaultPreferredLanguageCode ) } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift index 3baac58c5..7753d189d 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageBuildInputs.swift @@ -51,6 +51,9 @@ public struct MessageBuildInputs: Sendable, Hashable { public let showDayDivider: Bool /// Present only on channel incoming cluster-end rows. Never a JPEG. public let incomingAvatar: IncomingAvatarIdentity? + /// Already-decided Translation chrome. The builder copies this onto the + /// text payload and never calls the detector. + public let translation: MessageTranslationChrome? public init( messageID: UUID, @@ -76,7 +79,8 @@ public struct MessageBuildInputs: Sendable, Hashable { showSenderName: Bool, showNewMessagesDivider: Bool, showDayDivider: Bool = false, - incomingAvatar: IncomingAvatarIdentity? = nil + incomingAvatar: IncomingAvatarIdentity? = nil, + translation: MessageTranslationChrome? = nil ) { self.messageID = messageID self.previewState = previewState @@ -102,5 +106,6 @@ public struct MessageBuildInputs: Sendable, Hashable { self.showNewMessagesDivider = showNewMessagesDivider self.showDayDivider = showDayDivider self.incomingAvatar = incomingAvatar + self.translation = translation } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift index cca183d44..89ab05d4b 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageItem.swift @@ -50,6 +50,15 @@ public struct MessageItem: Identifiable, Sendable, Hashable { shouldRequestPreviewFetch ? id : nil } + public var translation: MessageTranslationChrome? { + for fragment in content { + if case let .text(payload) = fragment { + return payload.translation + } + } + return nil + } + /// Returns a new item with the supplied envelope, footer, and/or grouping /// overridden. Eliminates the 6-field rebuild at single-row mutation sites. public func with( diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift index b938b8f7d..c721c0823 100644 --- a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTextPayload.swift @@ -6,18 +6,23 @@ public struct MessageTextPayload: Sendable, Hashable { public let baseColor: BaseColorSlot public let isOutgoing: Bool public let currentUserName: String + /// Nil means no Translation offer row. `raw` / `formatted` always describe + /// the stored original; the view chooses the visible body from `phase`. + public let translation: MessageTranslationChrome? public init( raw: String, formatted: AttributedString?, baseColor: BaseColorSlot, isOutgoing: Bool, - currentUserName: String + currentUserName: String, + translation: MessageTranslationChrome? = nil ) { self.raw = raw self.formatted = formatted self.baseColor = baseColor self.isOutgoing = isOutgoing self.currentUserName = currentUserName + self.translation = translation } } diff --git a/MC1Services/Sources/MC1Services/Models/Rendering/MessageTranslationChrome.swift b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTranslationChrome.swift new file mode 100644 index 000000000..fda5ac96d --- /dev/null +++ b/MC1Services/Sources/MC1Services/Models/Rendering/MessageTranslationChrome.swift @@ -0,0 +1,45 @@ +import Foundation + +/// In-bubble Translation offer chrome. `nil` on the payload means no row; +/// stored message text is never replaced. +public struct MessageTranslationChrome: Sendable, Hashable { + public enum Phase: Sendable, Hashable { + case offer + case inProgress + case showing(translatedText: String, targetLanguageCode: String) + } + + public let phase: Phase + public let sourceLanguageCode: String + + public init(phase: Phase, sourceLanguageCode: String) { + self.phase = phase + self.sourceLanguageCode = sourceLanguageCode + } + + /// In-flight wins; showing wins only if its target still matches preferred. + /// Otherwise an offer exists only when detection differs from preferred. + public static func resolved( + detected: DetectedLanguage?, + phase: Phase?, + preferredLanguageCode: String + ) -> MessageTranslationChrome? { + guard case let .identified(languageCode: sourceLanguageCode) = detected else { return nil } + if let phase { + switch phase { + case .inProgress: + return MessageTranslationChrome(phase: phase, sourceLanguageCode: sourceLanguageCode) + case let .showing(_, targetLanguageCode): + if MessageLanguageDetector.isSameLanguage(targetLanguageCode, preferredLanguageCode) { + return MessageTranslationChrome(phase: phase, sourceLanguageCode: sourceLanguageCode) + } + case .offer: + break + } + } + if MessageLanguageDetector.isSameLanguage(sourceLanguageCode, preferredLanguageCode) { + return nil + } + return MessageTranslationChrome(phase: .offer, sourceLanguageCode: sourceLanguageCode) + } +} diff --git a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift index 158487069..728cc739e 100644 --- a/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift +++ b/MC1Services/Sources/MC1Services/Services/MessageFragmentBuilder.swift @@ -101,7 +101,8 @@ public enum MessageFragmentBuilder { formatted: inputs.formattedText, baseColor: inputs.baseColor, isOutgoing: message.isOutgoing, - currentUserName: envInputs.currentUserName + currentUserName: envInputs.currentUserName, + translation: inputs.translation ) } diff --git a/MC1Services/Sources/MC1Services/Services/MessageLanguageDetector.swift b/MC1Services/Sources/MC1Services/Services/MessageLanguageDetector.swift new file mode 100644 index 000000000..aeaaf6e4b --- /dev/null +++ b/MC1Services/Sources/MC1Services/Services/MessageLanguageDetector.swift @@ -0,0 +1,41 @@ +import Foundation +import NaturalLanguage + +/// Synchronous language detection. A new `NLLanguageRecognizer` per call — +/// Apple does not allow sharing one across threads. +public enum MessageLanguageDetector: Sendable { + public static let minimumLetterCount = 12 + public static let minimumConfidence = 0.85 + + /// High-confidence dominant language, or `.undetermined`. Bake/tile compare against preferred. + public static func dominantLanguage(for text: String) -> DetectedLanguage { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .undetermined } + + let letterCount = trimmed.reduce(into: 0) { count, character in + if character.isLetter { count += 1 } + } + guard letterCount >= minimumLetterCount else { return .undetermined } + + let recognizer = NLLanguageRecognizer() + // Do not set `languageHints`: a non-zero hint replaces the hypothesis at + // confidence 1, so a foreign body would never produce a Translation offer. + recognizer.processString(trimmed) + + let hypotheses = recognizer.languageHypotheses(withMaximum: 1) + guard let (language, confidence) = hypotheses.max(by: { $0.value < $1.value }) else { + return .undetermined + } + guard confidence >= minimumConfidence else { return .undetermined } + guard language != .undetermined else { return .undetermined } + return .identified(languageCode: language.rawValue) + } + + public static func isSameLanguage(_ lhs: String, _ rhs: String) -> Bool { + collapsedLanguageCode(lhs) == collapsedLanguageCode(rhs) + } + + public static func collapsedLanguageCode(_ identifier: String) -> String { + Locale.Language(identifier: identifier).languageCode?.identifier ?? identifier + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift b/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift index 72904a893..d474a300e 100644 --- a/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/EnvInputsThemeTokenTests.swift @@ -1,3 +1,4 @@ +import Foundation @testable import MC1Services import Testing @@ -17,7 +18,27 @@ struct EnvInputsThemeTokenTests { isOffline: false, currentUserName: "Tester", themeID: themeID, - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode + ) + } + + private func make(preferredLanguageCode: 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: EnvInputs.defaultThemeID, + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: preferredLanguageCode ) } @@ -27,8 +48,21 @@ struct EnvInputsThemeTokenTests { #expect(make(themeID: "default") == make(themeID: "default")) } + @Test + func `changing only preferredLanguageCode makes EnvInputs unequal`() { + #expect(make(preferredLanguageCode: "en") != make(preferredLanguageCode: "uk")) + #expect(make(preferredLanguageCode: "en") == make(preferredLanguageCode: "en")) + } + @Test func `EnvInputs.default carries the default theme id`() { #expect(EnvInputs.default.themeID == "default") } + + @Test + func `preferredLanguageCode from locale collapses region and script`() { + #expect(EnvInputs.preferredLanguageCode(from: Locale(identifier: "en-US")) == "en") + #expect(EnvInputs.preferredLanguageCode(from: Locale(identifier: "zh-Hans")) == "zh") + #expect(EnvInputs.preferredLanguageCode(from: Locale(identifier: "de-DE")) == "de") + } } diff --git a/MC1Services/Tests/MC1ServicesTests/MessageLanguageDetectorTests.swift b/MC1Services/Tests/MC1ServicesTests/MessageLanguageDetectorTests.swift new file mode 100644 index 000000000..137dc6686 --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/MessageLanguageDetectorTests.swift @@ -0,0 +1,46 @@ +import Foundation +@testable import MC1Services +import Testing + +@Suite("MessageLanguageDetector") +struct MessageLanguageDetectorTests { + private let german = "Guten Morgen, wie geht es dir heute?" + private let english = "Good morning, how are you today?" + + @Test + func `German sentence is detected`() { + #expect(MessageLanguageDetector.dominantLanguage(for: german) == .identified(languageCode: "de")) + } + + @Test + func `English sentence is detected and matches preferred en`() { + #expect(MessageLanguageDetector.dominantLanguage(for: english) == .identified(languageCode: "en")) + #expect(MessageLanguageDetector.isSameLanguage("en", "en-US")) + } + + @Test + func `too few letters is undetermined`() { + #expect(MessageLanguageDetector.dominantLanguage(for: "ok") == .undetermined) + #expect(MessageLanguageDetector.dominantLanguage(for: "kk") == .undetermined) + #expect(MessageLanguageDetector.dominantLanguage(for: "👍👍") == .undetermined) + #expect( + MessageLanguageDetector.dominantLanguage(for: "37.7749, -122.4194") == .undetermined + ) + #expect(MessageLanguageDetector.dominantLanguage(for: "") == .undetermined) + #expect(MessageLanguageDetector.dominantLanguage(for: " ") == .undetermined) + } + + @Test + func `eleven letters are below the floor`() { + let elevenLetters = "abcdefghijk" + #expect(elevenLetters.filter(\.isLetter).count == 11) + #expect(MessageLanguageDetector.dominantLanguage(for: elevenLetters) == .undetermined) + } + + @Test + func `zh-Hans and zh-Hant collapse to the same language`() { + #expect(MessageLanguageDetector.collapsedLanguageCode("zh-Hans") == "zh") + #expect(MessageLanguageDetector.collapsedLanguageCode("zh-Hant") == "zh") + #expect(MessageLanguageDetector.isSameLanguage("zh-Hans", "zh-Hant")) + } +} diff --git a/MC1Services/Tests/MC1ServicesTests/MessageTranslationChromeTests.swift b/MC1Services/Tests/MC1ServicesTests/MessageTranslationChromeTests.swift new file mode 100644 index 000000000..e0f6b111c --- /dev/null +++ b/MC1Services/Tests/MC1ServicesTests/MessageTranslationChromeTests.swift @@ -0,0 +1,111 @@ +@testable import MC1Services +import Testing + +@Suite("MessageTranslationChrome.resolved") +struct MessageTranslationChromeTests { + @Test + func `detected German against English is an offer`() { + let chrome = MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: nil, + preferredLanguageCode: "en" + ) + #expect(chrome?.phase == .offer) + #expect(chrome?.sourceLanguageCode == "de") + } + + @Test + func `same language as preferred is none`() { + #expect( + MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: nil, + preferredLanguageCode: "de" + ) == nil + ) + #expect( + MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: nil, + preferredLanguageCode: "de-DE" + ) == nil + ) + #expect( + MessageTranslationChrome.resolved( + detected: .identified(languageCode: "zh"), + phase: nil, + preferredLanguageCode: "zh-Hans" + ) == nil + ) + } + + @Test + func `present nil detection is none`() { + #expect( + MessageTranslationChrome.resolved( + detected: nil, + phase: nil, + preferredLanguageCode: "en" + ) == nil + ) + #expect( + MessageTranslationChrome.resolved( + detected: nil, + phase: .offer, + preferredLanguageCode: "en" + ) == nil + ) + #expect( + MessageTranslationChrome.resolved( + detected: .undetermined, + phase: .offer, + preferredLanguageCode: "en" + ) == nil + ) + } + + @Test + func `inProgress is kept`() { + let chrome = MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: .inProgress, + preferredLanguageCode: "en" + ) + #expect(chrome?.phase == .inProgress) + } + + @Test + func `showing is kept when the target still matches preferred`() { + let showing = MessageTranslationChrome.Phase.showing( + translatedText: "Hello", + targetLanguageCode: "en" + ) + let chrome = MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: showing, + preferredLanguageCode: "en" + ) + #expect(chrome?.phase == showing) + } + + @Test + func `showing for a different target falls back to offer`() { + let chrome = MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: .showing(translatedText: "Hello", targetLanguageCode: "en"), + preferredLanguageCode: "fr" + ) + #expect(chrome?.phase == .offer) + } + + @Test + func `showing for a different target that matches the source is none`() { + #expect( + MessageTranslationChrome.resolved( + detected: .identified(languageCode: "de"), + phase: .showing(translatedText: "Hello", targetLanguageCode: "en"), + preferredLanguageCode: "de" + ) == nil + ) + } +} diff --git a/MC1Tests/Services/TranslationLanguageResolverTests.swift b/MC1Tests/Services/TranslationLanguageResolverTests.swift new file mode 100644 index 000000000..dda4d61d6 --- /dev/null +++ b/MC1Tests/Services/TranslationLanguageResolverTests.swift @@ -0,0 +1,87 @@ +import Foundation +@testable import MC1 +import Testing + +@Suite("TranslationLanguageResolver") +struct TranslationLanguageResolverTests { + private let supported = [ + Locale.Language(identifier: "ja-JP"), + Locale.Language(identifier: "en-US"), + Locale.Language(identifier: "en-GB"), + Locale.Language(identifier: "zh-Hans-CN"), + Locale.Language(identifier: "zh-Hant-TW"), + Locale.Language(identifier: "de-DE") + ] + + @Test + func `compact Japanese maps to the supported ja-JP pack`() { + let resolved = TranslationLanguageResolver.resolve( + "ja", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.region == Locale.Region("JP")) + } + + @Test + func `simplified Chinese prefers Hans over Hant`() { + let resolved = TranslationLanguageResolver.resolve( + "zh-Hans", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.script == Locale.Language(identifier: "zh-Hans-CN").script) + } + + @Test + func `traditional Chinese maps to Hant even when Hans is listed first`() { + let resolved = TranslationLanguageResolver.resolve( + "zh-Hant", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.script == Locale.Language(identifier: "zh-Hant-TW").script) + #expect(resolved.region == Locale.Region("TW")) + } + + @Test + func `compact Chinese prefers the locale script`() { + let resolved = TranslationLanguageResolver.resolve( + "zh", + from: supported, + preferring: Locale(identifier: "zh-Hant-TW") + ) + #expect(resolved.script == Locale.Language(identifier: "zh-Hant-TW").script) + #expect(resolved.region == Locale.Region("TW")) + } + + @Test + func `compact English prefers the locale region`() { + let resolved = TranslationLanguageResolver.resolve( + "en", + from: supported, + preferring: Locale(identifier: "en-GB") + ) + #expect(resolved.region == Locale.Region("GB")) + } + + @Test + func `specified British English stays GB when preferring US`() { + let resolved = TranslationLanguageResolver.resolve( + "en-GB", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.region == Locale.Region("GB")) + } + + @Test + func `unknown language is returned unchanged`() { + let resolved = TranslationLanguageResolver.resolve( + "xx", + from: supported, + preferring: Locale(identifier: "en-US") + ) + #expect(resolved.languageCode == Locale.Language(identifier: "xx").languageCode) + } +} diff --git a/MC1Tests/Services/TranslationSessionConfigurationTests.swift b/MC1Tests/Services/TranslationSessionConfigurationTests.swift new file mode 100644 index 000000000..fab5b90cd --- /dev/null +++ b/MC1Tests/Services/TranslationSessionConfigurationTests.swift @@ -0,0 +1,26 @@ +import Foundation +@testable import MC1 +import Testing +import Translation + +@Suite("TranslationSession.Configuration replacing") +struct TranslationSessionConfigurationTests { + @Test + func `same pair invalidates so Equatable changes`() { + let source = Locale.Language(identifier: "de") + let target = Locale.Language(identifier: "en") + let first = TranslationSession.Configuration.replacing( + nil, + source: source, + target: target + ) + let same = TranslationSession.Configuration.replacing( + first, + source: source, + target: target + ) + #expect(same.source == first.source) + #expect(same.target == first.target) + #expect(same != first) + } +} diff --git a/MC1Tests/Services/TranslationSessionLauncherTests.swift b/MC1Tests/Services/TranslationSessionLauncherTests.swift new file mode 100644 index 000000000..0d47a3976 --- /dev/null +++ b/MC1Tests/Services/TranslationSessionLauncherTests.swift @@ -0,0 +1,67 @@ +import Foundation +@testable import MC1 +import Testing +import Translation + +@Suite("TranslationSessionLauncher") +@MainActor +struct TranslationSessionLauncherTests { + @Test + func `finished installed perform returns no configuration`() async { + guard #available(iOS 26.0, *) else { return } + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "de", + targetLanguageCode: "en", + generation: 1 + ) + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in .finished } + #expect(configuration == nil) + } + + @Test + func `needsDownload falls back to a configuration for the pair`() async { + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "ja-JP", + targetLanguageCode: "en-US", + generation: 1 + ) + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in .needsDownload } + #expect(configuration != nil) + } + + @Test + func `needsDownload then finished on the second installed session returns no configuration`() async { + guard #available(iOS 26.4, *) else { return } + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "de", + targetLanguageCode: "en", + generation: 1 + ) + var performCount = 0 + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in + performCount += 1 + return performCount == 1 ? .needsDownload : .finished + } + #expect(configuration == nil) + #expect(performCount == 2) + } + + @Test + func `wrapping remaps notInstalled`() { + guard #available(iOS 26.0, *) else { return } + let remapped = MessageTranslationNeedsDownloadError.wrapping(TranslationError.notInstalled) + #expect(remapped is MessageTranslationNeedsDownloadError) + } +} diff --git a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift index f98e13a8a..f27aed591 100644 --- a/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelPaginationTests.swift @@ -137,7 +137,8 @@ private func envInputsChangingAppearance() -> EnvInputs { isOffline: base.isOffline, currentUserName: base.currentUserName, themeID: base.themeID, - contentSizeCategory: base.contentSizeCategory + contentSizeCategory: base.contentSizeCategory, + preferredLanguageCode: base.preferredLanguageCode ) } diff --git a/MC1Tests/ViewModels/ChatViewModelTests.swift b/MC1Tests/ViewModels/ChatViewModelTests.swift index 0cf59146f..a1618fe32 100644 --- a/MC1Tests/ViewModels/ChatViewModelTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelTests.swift @@ -338,7 +338,8 @@ struct ChatViewModelTests { isOffline: EnvInputs.default.isOffline, currentUserName: EnvInputs.default.currentUserName, themeID: EnvInputs.default.themeID, - contentSizeCategory: EnvInputs.default.contentSizeCategory + contentSizeCategory: EnvInputs.default.contentSizeCategory, + preferredLanguageCode: EnvInputs.default.preferredLanguageCode ) viewModel.applyEnvInputs(darkEnv) await coordinator.buildItemsTask?.value @@ -376,7 +377,8 @@ struct ChatViewModelTests { isOffline: EnvInputs.default.isOffline, currentUserName: EnvInputs.default.currentUserName, themeID: Theme.ember.id, - contentSizeCategory: EnvInputs.default.contentSizeCategory + contentSizeCategory: EnvInputs.default.contentSizeCategory, + preferredLanguageCode: EnvInputs.default.preferredLanguageCode ) viewModel.applyEnvInputs(emberEnv) await coordinator.buildItemsTask?.value @@ -934,7 +936,8 @@ struct ChatViewModelImageGatingTests { isOffline: false, currentUserName: "Me", themeID: EnvInputs.defaultThemeID, - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) } @@ -1103,7 +1106,8 @@ struct ChatViewModelOrphanRecoveryTests { isOffline: false, currentUserName: "Me", themeID: EnvInputs.defaultThemeID, - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) } diff --git a/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift b/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift new file mode 100644 index 000000000..697230347 --- /dev/null +++ b/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift @@ -0,0 +1,346 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("ChatViewModel translation") +@MainActor +struct ChatViewModelTranslationTests { + private let german = "Guten Morgen, wie geht es dir heute?" + private let showingEnglish = MessageTranslationChrome.Phase.showing( + translatedText: "Hello", + targetLanguageCode: "en" + ) + + @Test + func `toggle offer goes inProgress then showing and leaves stored text unchanged`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + let original = message.text + + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + #expect(viewModel.translationSessionRequest?.messageID == message.id) + + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(viewModel.displayedText(for: message) == "Hello") + #expect(viewModel.messagesByID[message.id]?.text == original) + #expect(translator.translateCount == 1) + let reply = MentionUtilities.buildReplyText(mentionName: "Alice", messageText: original) + #expect(reply.contains("Guten Morg")) + #expect(!reply.contains("Hello")) + } + + @Test + func `toggle showing returns to offer and second translate hits cache`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.displayedText(for: message) == german) + + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(translator.translateCount == 1) + #expect(viewModel.translationSessionRequest == nil) + } + + @Test + func `translator throw sets errorMessage and returns to offer`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + translator.result = .failure(TranslationTestError.failed) + + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(viewModel.errorMessage != nil) + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.messagesByID[message.id]?.text == german) + } + + @Test + func `needs download leaves the in-flight request for the system sheet`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + translator.result = .failure(MessageTranslationNeedsDownloadError()) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(result == .needsDownload) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.translationSessionRequest?.messageID == message.id) + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + } + + @Test + func `cancellation leaves offer without errorMessage`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + translator.result = .failure(CancellationError()) + + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(viewModel.errorMessage == nil) + #expect(viewModel.translation(for: message.id)?.phase == .offer) + } + + @Test + func `download UI cancel leaves offer without errorMessage`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + translator.result = .failure(CocoaError(.userCancelled)) + + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + + #expect(viewModel.errorMessage == nil) + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) + #expect(viewModel.messagesByID[message.id]?.text == german) + } + + @Test + func `second tap while inProgress resets the first and applies the second`() async throws { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + + let first = germanMessage(timestamp: 1000) + let second = germanMessage(timestamp: 1001) + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + await coordinator.buildItemsTask?.value + + let gated = GatedMessageTranslator() + viewModel.performTranslationAction(for: first.id) + #expect(viewModel.bake.translationPhases[first.id] == .inProgress) + let firstRequest = try #require(viewModel.translationSessionRequest) + + async let pending = viewModel.performPendingTranslation(using: gated, for: firstRequest) + await gated.waitUntilEntered() + + viewModel.performTranslationAction(for: second.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: first.id)?.phase == .offer) + #expect(viewModel.translation(for: second.id)?.phase == .inProgress) + #expect(viewModel.translationSessionRequest?.messageID == second.id) + + gated.resume(returning: "Stale hello") + await pending + await coordinator.buildItemsTask?.value + + #expect(viewModel.bake.translationCache[first.id] == nil) + #expect(viewModel.bake.translationCache[second.id] == nil) + #expect(viewModel.translation(for: first.id)?.phase == .offer) + #expect(viewModel.translation(for: second.id)?.phase == .inProgress) + + let counting = CountingMessageTranslator() + try await performCurrent(viewModel, using: counting) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: second.id)?.phase == showingEnglish) + #expect(viewModel.bake.translationCache[first.id] == nil) + #expect(counting.translateCount == 1) + } + + @Test + func `cancelPendingTranslation while inProgress returns to offer`() async throws { + let (viewModel, coordinator, message, _) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + + viewModel.cancelPendingTranslation() + await coordinator.buildItemsTask?.value + + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) + } + + @Test + func `cancelPendingTranslation while showing leaves showing and cache`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(viewModel.displayedText(for: message) == "Hello") + + viewModel.cancelPendingTranslation() + await coordinator.buildItemsTask?.value + + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(viewModel.displayedText(for: message) == "Hello") + #expect(viewModel.translationSessionRequest == nil) + #expect(viewModel.bake.translationCache[message.id]?["en"] == "Hello") + #expect(translator.translateCount == 1) + } + + @Test + func `preferred language change does not reuse a translation for a different target`() async throws { + let (viewModel, coordinator, message, translator) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + await coordinator.buildItemsTask?.value + #expect(viewModel.displayedText(for: message) == "Hello") + + viewModel.applyEnvInputs(envInputs(preferredLanguageCode: "fr")) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.displayedText(for: message) == german) + + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + #expect(viewModel.translationSessionRequest?.targetLanguageCode == "fr") + #expect(viewModel.bake.translationCache[message.id]?["en"] == "Hello") + + viewModel.applyEnvInputs(envInputs(preferredLanguageCode: "en")) + await coordinator.buildItemsTask?.value + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == showingEnglish) + #expect(translator.translateCount == 1) + } + + @Test + func `preferred language change while inProgress returns to offer and drops the request`() async throws { + let (viewModel, coordinator, message, _) = try await seededGermanChat() + viewModel.performTranslationAction(for: message.id) + await coordinator.buildItemsTask?.value + #expect(viewModel.translation(for: message.id)?.phase == .inProgress) + #expect(viewModel.translationSessionRequest != nil) + + viewModel.applyEnvInputs(envInputs(preferredLanguageCode: "fr")) + await coordinator.buildItemsTask?.value + + #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) + } + + @Test + func `stale needs-download does not consume the newer request`() async throws { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + let first = germanMessage(timestamp: 1000) + let second = germanMessage(timestamp: 1001) + viewModel.appendMessageIfNew(first) + viewModel.appendMessageIfNew(second) + await coordinator.buildItemsTask?.value + + let gated = GatedMessageTranslator() + viewModel.performTranslationAction(for: first.id) + let requestA = try #require(viewModel.translationSessionRequest) + async let pending = viewModel.performPendingTranslation(using: gated, for: requestA) + await gated.waitUntilEntered() + + viewModel.performTranslationAction(for: second.id) + await coordinator.buildItemsTask?.value + gated.resume(throwing: MessageTranslationNeedsDownloadError()) + let result = await pending + await coordinator.buildItemsTask?.value + + #expect(result == .finished) + #expect(viewModel.translationSessionRequest?.messageID == second.id) + #expect(viewModel.translation(for: second.id)?.phase == .inProgress) + } + + @discardableResult + private func performCurrent( + _ viewModel: ChatViewModel, + using translator: any MessageTranslating + ) async throws -> TranslationPerformResult { + let request = try #require(viewModel.translationSessionRequest) + return await viewModel.performPendingTranslation(using: translator, for: request) + } + + private func seededGermanChat() async throws -> ( + ChatViewModel, ChatCoordinator, MessageDTO, CountingMessageTranslator + ) { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + let message = germanMessage(timestamp: 1000) + viewModel.appendMessageIfNew(message) + await coordinator.buildItemsTask?.value + let chrome = try #require(viewModel.translation(for: message.id)) + #expect(chrome.phase == .offer) + return (viewModel, coordinator, message, CountingMessageTranslator()) + } + + private func germanMessage(timestamp: UInt32) -> MessageDTO { + makeMessage(timestamp: timestamp, text: german) + } + + private func envInputs(preferredLanguageCode: String) -> EnvInputs { + let base = EnvInputs.default + return EnvInputs( + autoPlayGIFs: base.autoPlayGIFs, + showIncomingPath: base.showIncomingPath, + showIncomingHopCount: base.showIncomingHopCount, + showIncomingRegion: base.showIncomingRegion, + showIncomingSendTime: base.showIncomingSendTime, + previewsEnabled: base.previewsEnabled, + isHighContrast: base.isHighContrast, + isDark: base.isDark, + showMapPreviews: base.showMapPreviews, + isOffline: base.isOffline, + currentUserName: base.currentUserName, + themeID: base.themeID, + contentSizeCategory: base.contentSizeCategory, + preferredLanguageCode: preferredLanguageCode + ) + } + + private func makeMessage(timestamp: UInt32, text: String) -> MessageDTO { + MessageDTO( + id: UUID(), + radioID: UUID(), + contactID: UUID(), + channelIndex: nil, + text: text, + timestamp: timestamp, + createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), + direction: .incoming, + status: .delivered, + 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 enum TranslationTestError: Error { + case failed +} + +@MainActor +private final class CountingMessageTranslator: MessageTranslating { + var translateCount = 0 + var result: Result = .success("Hello") + + func translate(_: String) async throws -> String { + translateCount += 1 + return try result.get() + } +} diff --git a/MC1Tests/ViewModels/GatedMessageTranslator.swift b/MC1Tests/ViewModels/GatedMessageTranslator.swift new file mode 100644 index 000000000..cfb385e42 --- /dev/null +++ b/MC1Tests/ViewModels/GatedMessageTranslator.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import MC1 + +/// Test translator that parks inside `translate` until `resume` is called. +@MainActor +final class GatedMessageTranslator: MessageTranslating { + private var waitForEnter: CheckedContinuation? + private var gate: CheckedContinuation? + private var didEnter = false + private(set) var translateCount = 0 + + func translate(_: String) async throws -> String { + translateCount += 1 + return try await withCheckedThrowingContinuation { continuation in + gate = continuation + didEnter = true + if let waitForEnter { + waitForEnter.resume() + self.waitForEnter = nil + } + } + } + + func waitUntilEntered() async { + if didEnter { return } + await withCheckedContinuation { continuation in + waitForEnter = continuation + } + } + + func resume(returning value: String) { + gate?.resume(returning: value) + gate = nil + } + + func resume(throwing error: Error) { + gate?.resume(throwing: error) + gate = nil + } +} diff --git a/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift b/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift new file mode 100644 index 000000000..de1f36a45 --- /dev/null +++ b/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift @@ -0,0 +1,143 @@ +import Foundation +@testable import MC1 +@testable import MC1Services +import Testing + +@Suite("RoomConversationViewModel translation") +@MainActor +struct RoomConversationViewModelTranslationTests { + private let german = "Guten Morgen, wie geht es dir heute?" + private let sessionID = UUID() + private let showingEnglish = MessageTranslationChrome.Phase.showing( + translatedText: "Hello", + targetLanguageCode: "en" + ) + + @Test + func `translation-only change makes RoomTiledRow unequal`() { + let message = roomMessage(text: german) + let without = RoomTiledRow( + message: message, + showTimestamp: true, + showSenderName: true, + showAvatar: true, + translation: nil + ) + let withOffer = RoomTiledRow( + message: message, + showTimestamp: true, + showSenderName: true, + showAvatar: true, + translation: MessageTranslationChrome(phase: .offer, sourceLanguageCode: "de") + ) + #expect(without != withOffer) + } + + @Test + func `detector maps land chrome on the materialized row`() throws { + let viewModel = RoomConversationViewModel() + viewModel.preferredLanguageCode = "en" + viewModel.appendMessageIfNew(roomMessage(text: german)) + + let row = try #require(viewModel.tiledRows.first) + #expect(row.translation?.phase == .offer) + #expect(row.translation?.sourceLanguageCode == "de") + } + + @Test + func `toggle and fake translator show then restore without writing stored text`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + + viewModel.performTranslationAction(for: message.id) + #expect(viewModel.tiledRows.first?.translation?.phase == .inProgress) + try await performCurrent(viewModel, using: translator) + + #expect(viewModel.tiledRows.first?.translation?.phase == showingEnglish) + #expect(viewModel.displayedText(for: message) == "Hello") + #expect(viewModel.messages.first?.text == german) + #expect(viewModel.errorMessage == nil) + + viewModel.performTranslationAction(for: message.id) + #expect(viewModel.tiledRows.first?.translation?.phase == .offer) + #expect(viewModel.messages.first?.text == german) + + viewModel.performTranslationAction(for: message.id) + #expect(viewModel.tiledRows.first?.translation?.phase == showingEnglish) + #expect(translator.translateCount == 1) + } + + @Test + func `needs download leaves the in-flight request for the system sheet`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + translator.result = .failure(MessageTranslationNeedsDownloadError()) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + + #expect(result == .needsDownload) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.translationSessionRequest?.messageID == message.id) + #expect(viewModel.tiledRows.first?.translation?.phase == .inProgress) + } + + @Test + func `translator throw sets errorMessage and returns to offer`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + translator.result = .failure(RoomTranslationTestError.failed) + + viewModel.performTranslationAction(for: message.id) + try await performCurrent(viewModel, using: translator) + + #expect(viewModel.errorMessage != nil) + #expect(viewModel.tiledRows.first?.translation?.phase == .offer) + #expect(viewModel.messages.first?.text == german) + #expect(viewModel.translationSessionRequest == nil) + } + + @discardableResult + private func performCurrent( + _ viewModel: RoomConversationViewModel, + using translator: any MessageTranslating + ) async throws -> TranslationPerformResult { + let request = try #require(viewModel.translationSessionRequest) + return await viewModel.performPendingTranslation(using: translator, for: request) + } + + private func seededGermanRoom() -> RoomConversationViewModel { + let viewModel = RoomConversationViewModel() + viewModel.preferredLanguageCode = "en" + viewModel.appendMessageIfNew(roomMessage(text: german)) + return viewModel + } + + private func roomMessage(text: String, timestamp: UInt32 = 100) -> RoomMessageDTO { + RoomMessageDTO( + sessionID: sessionID, + authorKeyPrefix: Data([0xAA]), + authorName: "Alice", + text: text, + timestamp: timestamp + ) + } +} + +private enum RoomTranslationTestError: Error { + case failed +} + +@MainActor +private final class CountingMessageTranslator: MessageTranslating { + var translateCount = 0 + var result: Result = .success("Hello") + + func translate(_: String) async throws -> String { + translateCount += 1 + return try result.get() + } +} diff --git a/MC1Tests/Views/Chats/ChatTimelineTests.swift b/MC1Tests/Views/Chats/ChatTimelineTests.swift index 19ad56e0a..a7ca27505 100644 --- a/MC1Tests/Views/Chats/ChatTimelineTests.swift +++ b/MC1Tests/Views/Chats/ChatTimelineTests.swift @@ -504,11 +504,52 @@ struct ChatTimelineTests { _ = await timeline.open(.dm(contact), reactions: nil, populateMode: .replace) timeline.apply(.previewState(messageID: message.id, state: .loading)) #expect(!timeline.bake.cachedURLs.isEmpty) + timeline.bake.detectedLanguages[message.id] = .identified(languageCode: "de") + timeline.bake.translationPhases[message.id] = .offer + timeline.bake.translationCache[message.id] = ["en": "Hello"] timeline.clearBakeState() #expect(timeline.bake.previewStates.isEmpty) #expect(timeline.bake.cachedURLs.isEmpty) #expect(timeline.bake.loadedPreviews.isEmpty) + #expect(timeline.bake.detectedLanguages.isEmpty) + #expect(timeline.bake.translationPhases.isEmpty) + #expect(timeline.bake.translationCache.isEmpty) + } + + @Test + func `removeBakeState drops one message translation maps and leaves others`() async throws { + let dataStore = try makeStore() + let radioID = UUID() + let contact = makeContact(radioID: radioID) + let message = makeDirectMessage( + radioID: radioID, contactID: contact.id, + timestamp: 1000, text: "https://example.com/article" + ) + try await dataStore.saveMessage(message) + + let timeline = makeBoundTimeline( + dataStore: dataStore, + conversationID: .dm(radioID: radioID, contactID: contact.id) + ) + _ = await timeline.open(.dm(contact), reactions: nil, populateMode: .replace) + + let otherID = UUID() + timeline.bake.detectedLanguages[message.id] = .identified(languageCode: "de") + timeline.bake.detectedLanguages[otherID] = .identified(languageCode: "fr") + timeline.bake.translationPhases[message.id] = .offer + timeline.bake.translationPhases[otherID] = .inProgress + timeline.bake.translationCache[message.id] = ["en": "Hello"] + timeline.bake.translationCache[otherID] = ["en": "Bonjour"] + + timeline.removeBakeState(for: message.id) + + #expect(timeline.bake.detectedLanguages[message.id] == nil) + #expect(timeline.bake.detectedLanguages[otherID] == .identified(languageCode: "fr")) + #expect(timeline.bake.translationPhases[message.id] == nil) + #expect(timeline.bake.translationPhases[otherID] == .inProgress) + #expect(timeline.bake.translationCache[message.id] == nil) + #expect(timeline.bake.translationCache[otherID]?["en"] == "Bonjour") } } diff --git a/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift b/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift index ec8fdd4f9..257b058d1 100644 --- a/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift +++ b/MC1Tests/Views/Chats/ChatViewModelAdmissionTests.swift @@ -299,7 +299,8 @@ struct ChatViewModelAdmissionTests { isOffline: false, currentUserName: "Me", themeID: EnvInputs.defaultThemeID, - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) } diff --git a/MC1Tests/Views/Chats/Components/MessageBubbleTestData.swift b/MC1Tests/Views/Chats/Components/MessageBubbleTestData.swift index edd6435ed..8deca9bf2 100644 --- a/MC1Tests/Views/Chats/Components/MessageBubbleTestData.swift +++ b/MC1Tests/Views/Chats/Components/MessageBubbleTestData.swift @@ -167,7 +167,8 @@ enum MessageBubbleTestData { isOffline: false, currentUserName: currentUserName, themeID: "default", - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) let item = MessageFragmentBuilder.makeItem(for: message, inputs: inputs, envInputs: envInputs) diff --git a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderFixtures.swift b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderFixtures.swift index 441b24ac9..4cc42ebfe 100644 --- a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderFixtures.swift +++ b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderFixtures.swift @@ -142,7 +142,8 @@ enum MessageFragmentBuilderFixtures { isOffline: false, currentUserName: isOutgoing ? "Me" : "Sender", themeID: "default", - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) } } diff --git a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift index c4064bab9..f63066507 100644 --- a/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift +++ b/MC1Tests/Views/Chats/Models/MessageFragmentBuilderTests.swift @@ -20,6 +20,54 @@ struct MessageFragmentBuilderTests { #expect(text.raw == "hello") } + @Test + func `translation chrome copies onto the payload and leaves raw original`() { + let original = "Guten Morgen, wie geht es dir heute?" + let message = makeMessage(text: original) + let offer = MessageFragmentBuilder.makeItem( + for: message, + inputs: makeInputs( + messageID: message.id, + translation: MessageTranslationChrome(phase: .offer, sourceLanguageCode: "de") + ), + envInputs: makeEnvInputs() + ) + let showing = MessageFragmentBuilder.makeItem( + for: message, + inputs: makeInputs( + messageID: message.id, + translation: MessageTranslationChrome( + phase: .showing(translatedText: "Hello", targetLanguageCode: "en"), + sourceLanguageCode: "de" + ) + ), + envInputs: makeEnvInputs() + ) + let none = MessageFragmentBuilder.makeItem( + for: message, + inputs: makeInputs(messageID: message.id, translation: nil), + envInputs: makeEnvInputs() + ) + guard case let .text(offerPayload) = offer.content[0], + case let .text(showingPayload) = showing.content[0], + case let .text(nonePayload) = none.content[0] else { + Issue.record("expected .text fragment") + return + } + #expect(offerPayload.raw == original) + #expect(offerPayload.translation?.phase == .offer) + #expect(showingPayload.raw == original) + #expect( + showingPayload.translation?.phase + == .showing(translatedText: "Hello", targetLanguageCode: "en") + ) + #expect(nonePayload.raw == original) + #expect(nonePayload.translation == nil) + #expect(offer != showing) + #expect(offer != none) + #expect(showing != none) + } + @Test func `reaction summary appears after the text fragment`() { let message = makeMessage(text: "hi", reactionSummary: "👍:1") @@ -669,7 +717,8 @@ struct MessageFragmentBuilderTests { isOffline: isOffline, currentUserName: currentUserName, themeID: "default", - contentSizeCategory: EnvInputs.defaultContentSizeCategory + contentSizeCategory: EnvInputs.defaultContentSizeCategory, + preferredLanguageCode: EnvInputs.defaultPreferredLanguageCode ) } @@ -726,7 +775,8 @@ struct MessageFragmentBuilderTests { showTimestamp: Bool = false, showDirectionGap: Bool = false, showSenderName: Bool = false, - showNewMessagesDivider: Bool = false + showNewMessagesDivider: Bool = false, + translation: MessageTranslationChrome? = nil ) -> MessageBuildInputs { MessageBuildInputs( messageID: messageID, @@ -745,7 +795,8 @@ struct MessageFragmentBuilderTests { showTimestamp: showTimestamp, showDirectionGap: showDirectionGap, showSenderName: showSenderName, - showNewMessagesDivider: showNewMessagesDivider + showNewMessagesDivider: showNewMessagesDivider, + translation: translation ) } From 63ee95ad611a680412ba853931de5518c69f716c Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:00:47 -0700 Subject: [PATCH 39/47] fix(ci): close PRs after injecting checklist --- .github/workflows/pr-checklist.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-checklist.yml b/.github/workflows/pr-checklist.yml index 724142b99..4818cabd7 100644 --- a/.github/workflows/pr-checklist.yml +++ b/.github/workflows/pr-checklist.yml @@ -30,9 +30,9 @@ jobs: const BODY_MARKER = ''; // PRs opened via the API/CLI (incl. AI agents) bypass the repository - // template, so the checklist never appears. When it is absent we inject - // this into the body once; editing the body re-fires this workflow, - // which then enforces the checkbox state. + // template, so the checklist never appears. Inject it so the + // contributor has boxes to check; GITHUB_TOKEN body edits do not + // re-fire this workflow, so the same run must also close below. const TEMPLATE = [ BODY_MARKER, '## Tested on', @@ -78,8 +78,8 @@ jobs: const common = { owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number }; // The checklist is partly or wholly absent (template was bypassed). - // Inject it once so the contributor actually sees the boxes; the - // resulting body edit re-fires this workflow to enforce them. + // Inject it so the contributor sees the boxes, then fall through and + // close. A later human checkbox edit still retriggers `edited`. if (pr.state === 'open' && !allPresent && !body.includes(BODY_MARKER)) { await github.rest.pulls.update({ owner: context.repo.owner, @@ -87,8 +87,7 @@ jobs: pull_number: pr.number, body: body ? `${body}\n\n${TEMPLATE}` : TEMPLATE, }); - core.info(`Injected checklist template into PR #${pr.number}; awaiting completion.`); - return; + core.info(`Injected checklist template into PR #${pr.number}.`); } // Was this PR previously closed by the bot? Only those are eligible for auto-reopen. From b52da3daf714b7538c5aa7ea10d869da1401cf84 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:08:42 -0700 Subject: [PATCH 40/47] fix(chats): sibling overlay for sheet translate - Put `.translationPresentation` on a later `.background` sibling in chat and room - Overlay wrapping `.conversationTranslationSession` failed in airplane mode even with packs on disk --- MC1/Views/Chats/ChatConversationView.swift | 14 ++++++++++---- .../RemoteNodes/Rooms/RoomConversationView.swift | 14 ++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index 4198ddcbe..e98e2c400 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -289,10 +289,16 @@ struct ChatConversationView: View { await chatViewModel.performPendingTranslation(using: translator, for: request) } ) - .translationPresentation( - isPresented: $showSystemTranslation, - text: systemTranslationText - ) + // Overlay after `.conversationTranslationSession`: `.translationPresentation` + // must not sit in that modifier's `.translationTask` content tree. + .background { + Color.clear + .accessibilityHidden(true) + .translationPresentation( + isPresented: $showSystemTranslation, + text: systemTranslationText + ) + } .onDisappear { // Load-bearing on iPad: MainSidebarView pins the Chats detail stack with // `.id(chatsSelectedRoute.conversationID)`, so a detail swap tears down this view's diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index 36b05a27f..c0affb729 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -143,10 +143,16 @@ struct RoomConversationView: View { await viewModel.performPendingTranslation(using: translator, for: request) } ) - .translationPresentation( - isPresented: $showSystemTranslation, - text: systemTranslationText - ) + // Overlay after `.conversationTranslationSession`: `.translationPresentation` + // must not sit in that modifier's `.translationTask` content tree. + .background { + Color.clear + .accessibilityHidden(true) + .translationPresentation( + isPresented: $showSystemTranslation, + text: systemTranslationText + ) + } .onChange(of: locale) { _, newLocale in viewModel.applyPreferredLanguageCode(EnvInputs.preferredLanguageCode(from: newLocale)) } From e1f61b85f5d0b8f69edfbdb5a35b47c252139656 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:30:26 -0700 Subject: [PATCH 41/47] feat(chats): overlay fallback when session fails - Present the system overlay when in-bubble TranslationSession throws - Quiet cancel and pack download stay as they are, with no error alert --- MC1/Services/TranslationPerformResult.swift | 1 + MC1/Services/TranslationSessionLauncher.swift | 2 +- MC1/Views/Chats/ChatConversationView.swift | 7 +++- ...nversationTranslationSessionModifier.swift | 2 + .../ViewModel/ChatViewModel+Translation.swift | 9 ++--- .../Rooms/RoomConversationView.swift | 7 +++- ...oomConversationViewModel+Translation.swift | 9 ++--- .../TranslationSessionLauncherTests.swift | 37 ++++++++++++++++++ .../ChatViewModelTranslationTests.swift | 16 +++++--- ...onversationViewModelTranslationTests.swift | 39 +++++++++++++++++-- 10 files changed, 107 insertions(+), 22 deletions(-) diff --git a/MC1/Services/TranslationPerformResult.swift b/MC1/Services/TranslationPerformResult.swift index 06e0c1f9c..75756c40b 100644 --- a/MC1/Services/TranslationPerformResult.swift +++ b/MC1/Services/TranslationPerformResult.swift @@ -3,4 +3,5 @@ import Foundation enum TranslationPerformResult: Equatable, Sendable { case finished case needsDownload + case presentSystemOverlay(text: String) } diff --git a/MC1/Services/TranslationSessionLauncher.swift b/MC1/Services/TranslationSessionLauncher.swift index 70bd36542..57e7f5198 100644 --- a/MC1/Services/TranslationSessionLauncher.swift +++ b/MC1/Services/TranslationSessionLauncher.swift @@ -27,7 +27,7 @@ enum TranslationSessionLauncher { let result = await perform(InstalledPackTranslator(session: session)) guard !Task.isCancelled else { return nil } switch result { - case .finished: + case .finished, .presentSystemOverlay: return nil case .needsDownload: continue diff --git a/MC1/Views/Chats/ChatConversationView.swift b/MC1/Views/Chats/ChatConversationView.swift index e98e2c400..456bf4468 100644 --- a/MC1/Views/Chats/ChatConversationView.swift +++ b/MC1/Views/Chats/ChatConversationView.swift @@ -286,7 +286,12 @@ struct ChatConversationView: View { configuration: $translationConfiguration, request: $chatViewModel.translationSessionRequest, perform: { translator, request in - await chatViewModel.performPendingTranslation(using: translator, for: request) + let result = await chatViewModel.performPendingTranslation(using: translator, for: request) + if case let .presentSystemOverlay(text: text) = result { + systemTranslationText = text + showSystemTranslation = true + } + return result } ) // Overlay after `.conversationTranslationSession`: `.translationPresentation` diff --git a/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift b/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift index 154909c26..939726ace 100644 --- a/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift +++ b/MC1/Views/Chats/Components/ConversationTranslationSessionModifier.swift @@ -17,6 +17,8 @@ struct ConversationTranslationSessionModifier: ViewModifier { .translationTask(configuration) { session in guard let sessionBoundRequest, request?.generation == sessionBoundRequest.generation else { return } + // Overlay presentation is a `perform` side effect so `.translationTask` + // and TranslationSessionLauncher share one closure. _ = await perform( TranslationSessionMessageTranslator(session: session), sessionBoundRequest diff --git a/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift b/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift index 82011fc1b..989a1cd16 100644 --- a/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift +++ b/MC1/Views/Chats/ViewModel/ChatViewModel+Translation.swift @@ -43,7 +43,6 @@ extension ChatViewModel { /// Apply `translator` only while `request.generation` is still current. /// Never writes `MessageDTO.text`. - @discardableResult func performPendingTranslation( using translator: any MessageTranslating, for request: TranslationSessionRequest @@ -80,12 +79,12 @@ extension ChatViewModel { } catch { guard isCurrent(request) else { return .finished } bake.translationPhases[capturedID] = .offer - if !isQuietTranslationCancel(error) { - errorMessage = error.userFacingMessage - } translationSessionRequest = nil timeline.rebakeRow(capturedID) - return .finished + if isQuietTranslationCancel(error) { + return .finished + } + return .presentSystemOverlay(text: text) } } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift index c0affb729..af6d4cdea 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationView.swift @@ -140,7 +140,12 @@ struct RoomConversationView: View { configuration: $translationConfiguration, request: $viewModel.translationSessionRequest, perform: { translator, request in - await viewModel.performPendingTranslation(using: translator, for: request) + let result = await viewModel.performPendingTranslation(using: translator, for: request) + if case let .presentSystemOverlay(text: text) = result { + systemTranslationText = text + showSystemTranslation = true + } + return result } ) // Overlay after `.conversationTranslationSession`: `.translationPresentation` diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift index 96f304c44..063811584 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift @@ -44,7 +44,6 @@ extension RoomConversationViewModel { ) } - @discardableResult func performPendingTranslation( using translator: any MessageTranslating, for request: TranslationSessionRequest @@ -81,12 +80,12 @@ extension RoomConversationViewModel { } catch { guard isCurrent(request) else { return .finished } translationPhases[capturedID] = .offer - if !isQuietTranslationCancel(error) { - errorMessage = error.userFacingMessage - } translationSessionRequest = nil refreshTiledRows() - return .finished + if isQuietTranslationCancel(error) { + return .finished + } + return .presentSystemOverlay(text: text) } } diff --git a/MC1Tests/Services/TranslationSessionLauncherTests.swift b/MC1Tests/Services/TranslationSessionLauncherTests.swift index 0d47a3976..4d56a1fa8 100644 --- a/MC1Tests/Services/TranslationSessionLauncherTests.swift +++ b/MC1Tests/Services/TranslationSessionLauncherTests.swift @@ -64,4 +64,41 @@ struct TranslationSessionLauncherTests { let remapped = MessageTranslationNeedsDownloadError.wrapping(TranslationError.notInstalled) #expect(remapped is MessageTranslationNeedsDownloadError) } + + @Test + func `presentSystemOverlay returns no configuration`() async { + guard #available(iOS 26.0, *) else { return } + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "de", + targetLanguageCode: "en", + generation: 1 + ) + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in .presentSystemOverlay(text: "Guten Morgen") } + #expect(configuration == nil) + } + + @Test + func `presentSystemOverlay on the first installed session does not try the second`() async { + guard #available(iOS 26.4, *) else { return } + let request = TranslationSessionRequest( + messageID: UUID(), + sourceLanguageCode: "de", + targetLanguageCode: "en", + generation: 1 + ) + var performCount = 0 + let configuration = await TranslationSessionLauncher.launch( + request: request, + replacing: nil + ) { _ in + performCount += 1 + return .presentSystemOverlay(text: "Guten Morgen") + } + #expect(configuration == nil) + #expect(performCount == 1) + } } diff --git a/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift b/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift index 697230347..f2220031e 100644 --- a/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift @@ -54,16 +54,18 @@ struct ChatViewModelTranslationTests { } @Test - func `translator throw sets errorMessage and returns to offer`() async throws { + func `translator throw presents system overlay and returns to offer`() async throws { let (viewModel, coordinator, message, translator) = try await seededGermanChat() translator.result = .failure(TranslationTestError.failed) viewModel.performTranslationAction(for: message.id) - try await performCurrent(viewModel, using: translator) + let result = try await performCurrent(viewModel, using: translator) await coordinator.buildItemsTask?.value - #expect(viewModel.errorMessage != nil) + #expect(result == .presentSystemOverlay(text: german)) + #expect(viewModel.errorMessage == nil) #expect(viewModel.translation(for: message.id)?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) #expect(viewModel.messagesByID[message.id]?.text == german) } @@ -88,9 +90,10 @@ struct ChatViewModelTranslationTests { translator.result = .failure(CancellationError()) viewModel.performTranslationAction(for: message.id) - try await performCurrent(viewModel, using: translator) + let result = try await performCurrent(viewModel, using: translator) await coordinator.buildItemsTask?.value + #expect(result == .finished) #expect(viewModel.errorMessage == nil) #expect(viewModel.translation(for: message.id)?.phase == .offer) } @@ -101,9 +104,10 @@ struct ChatViewModelTranslationTests { translator.result = .failure(CocoaError(.userCancelled)) viewModel.performTranslationAction(for: message.id) - try await performCurrent(viewModel, using: translator) + let result = try await performCurrent(viewModel, using: translator) await coordinator.buildItemsTask?.value + #expect(result == .finished) #expect(viewModel.errorMessage == nil) #expect(viewModel.translation(for: message.id)?.phase == .offer) #expect(viewModel.translationSessionRequest == nil) @@ -137,7 +141,7 @@ struct ChatViewModelTranslationTests { #expect(viewModel.translationSessionRequest?.messageID == second.id) gated.resume(returning: "Stale hello") - await pending + #expect(await pending == .finished) await coordinator.buildItemsTask?.value #expect(viewModel.bake.translationCache[first.id] == nil) diff --git a/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift b/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift index de1f36a45..cc0c240b4 100644 --- a/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift +++ b/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift @@ -85,19 +85,52 @@ struct RoomConversationViewModelTranslationTests { } @Test - func `translator throw sets errorMessage and returns to offer`() async throws { + func `translator throw presents system overlay and returns to offer`() async throws { let viewModel = seededGermanRoom() let message = try #require(viewModel.messages.first) let translator = CountingMessageTranslator() translator.result = .failure(RoomTranslationTestError.failed) viewModel.performTranslationAction(for: message.id) - try await performCurrent(viewModel, using: translator) + let result = try await performCurrent(viewModel, using: translator) - #expect(viewModel.errorMessage != nil) + #expect(result == .presentSystemOverlay(text: german)) + #expect(viewModel.errorMessage == nil) #expect(viewModel.tiledRows.first?.translation?.phase == .offer) + #expect(viewModel.translationSessionRequest == nil) #expect(viewModel.messages.first?.text == german) + } + + @Test + func `cancellation leaves offer without errorMessage`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + translator.result = .failure(CancellationError()) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + + #expect(result == .finished) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.tiledRows.first?.translation?.phase == .offer) + } + + @Test + func `download UI cancel leaves offer without errorMessage`() async throws { + let viewModel = seededGermanRoom() + let message = try #require(viewModel.messages.first) + let translator = CountingMessageTranslator() + translator.result = .failure(CocoaError(.userCancelled)) + + viewModel.performTranslationAction(for: message.id) + let result = try await performCurrent(viewModel, using: translator) + + #expect(result == .finished) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.tiledRows.first?.translation?.phase == .offer) #expect(viewModel.translationSessionRequest == nil) + #expect(viewModel.messages.first?.text == german) } @discardableResult From 12ee6c8bce0a609d1fbda9ac7c0e0f0ec08bea7e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:40:48 -0700 Subject: [PATCH 42/47] fix(chats): hide translate on outgoing messages - Skip language detection and chrome on outgoing chat and from-self room rows - Drop isOutgoing from BubbleTranslationControl --- .../Components/BubbleFragmentStack.swift | 1 - .../Components/BubbleTranslationControl.swift | 13 ++------ .../ChatMessageBakeState+ItemBuild.swift | 13 +++++--- ...oomConversationViewModel+Translation.swift | 2 ++ .../RemoteNodes/Rooms/RoomMessageBubble.swift | 1 - .../ChatViewModelTranslationTests.swift | 31 ++++++++++++++++--- ...onversationViewModelTranslationTests.swift | 21 +++++++++++-- 7 files changed, 58 insertions(+), 24 deletions(-) diff --git a/MC1/Views/Chats/Components/BubbleFragmentStack.swift b/MC1/Views/Chats/Components/BubbleFragmentStack.swift index d10ff8b0d..f7b9cab32 100644 --- a/MC1/Views/Chats/Components/BubbleFragmentStack.swift +++ b/MC1/Views/Chats/Components/BubbleFragmentStack.swift @@ -72,7 +72,6 @@ struct BubbleFragmentStack: View, Equatable { if let chrome = layout.textPayload?.translation { BubbleTranslationControl( phase: chrome.phase, - isOutgoing: item.envelope.isOutgoing, onTap: { callbacks.onTranslationAction?() } ) } diff --git a/MC1/Views/Chats/Components/BubbleTranslationControl.swift b/MC1/Views/Chats/Components/BubbleTranslationControl.swift index 92e07bb99..631b8d1b8 100644 --- a/MC1/Views/Chats/Components/BubbleTranslationControl.swift +++ b/MC1/Views/Chats/Components/BubbleTranslationControl.swift @@ -5,19 +5,16 @@ import SwiftUI /// yield to the bubble long-press. VoiceOver-hidden; the bubble exposes the action. struct BubbleTranslationControl: View { let phase: MessageTranslationChrome.Phase - let isOutgoing: Bool let onTap: () -> Void - @Environment(\.appTheme) private var theme - var body: some View { label .font(.caption) .imageScale(.small) .labelStyle(.titleAndIcon) - .foregroundStyle(foreground) - .tint(isOutgoing ? theme.outgoingTextColor : .accentColor) - .multilineTextAlignment(isOutgoing ? .trailing : .leading) + .foregroundStyle(Color.accentColor) + .tint(Color.accentColor) + .multilineTextAlignment(.leading) .contentShape(.rect) .accessibilityHidden(true) .tapYieldingToLongPress { @@ -37,10 +34,6 @@ struct BubbleTranslationControl: View { } } - private var foreground: Color { - isOutgoing ? theme.outgoingTextColor : .accentColor - } - @ViewBuilder private var label: some View { switch phase { diff --git a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift index 54f5c08c6..bde80b4cd 100644 --- a/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift +++ b/MC1/Views/Chats/ViewModel/ChatMessageBakeState+ItemBuild.swift @@ -210,17 +210,20 @@ extension ChatMessageBakeState { showNewMessagesDivider: message.id == newMessagesDividerMessageID, showDayDivider: flags.showDayDivider, incomingAvatar: incomingAvatar, - translation: MessageTranslationChrome.resolved( - detected: detectedLanguages[message.id], - phase: translationPhases[message.id], - preferredLanguageCode: envInputs.preferredLanguageCode - ) + translation: message.isOutgoing + ? nil + : MessageTranslationChrome.resolved( + detected: detectedLanguages[message.id], + phase: translationPhases[message.id], + preferredLanguageCode: envInputs.preferredLanguageCode + ) ) } /// Writes a missing detection key. Stores `.undetermined` so a locale /// change does not re-run the recognizer. func seedDetectedLanguageIfNeeded(for message: MessageDTO) { + guard !message.isOutgoing else { return } guard detectedLanguages[message.id] == nil else { return } detectedLanguages[message.id] = MessageLanguageDetector.dominantLanguage(for: message.text) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift index 063811584..bdd76f0b3 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomConversationViewModel+Translation.swift @@ -132,6 +132,7 @@ extension RoomConversationViewModel { seedDetectedLanguages() var translations: [UUID: MessageTranslationChrome] = [:] for message in messages { + guard !message.isFromSelf else { continue } if let chrome = MessageTranslationChrome.resolved( detected: detectedLanguages[message.id], phase: translationPhases[message.id], @@ -145,6 +146,7 @@ extension RoomConversationViewModel { private func seedDetectedLanguages() { for message in messages { + guard !message.isFromSelf else { continue } guard detectedLanguages[message.id] == nil else { continue } detectedLanguages[message.id] = MessageLanguageDetector.dominantLanguage(for: message.text) } diff --git a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift index 29650d95d..5bcfa378c 100644 --- a/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift +++ b/MC1/Views/RemoteNodes/Rooms/RoomMessageBubble.swift @@ -227,7 +227,6 @@ private struct BubbleContent: View { if let chrome = translation { BubbleTranslationControl( phase: chrome.phase, - isOutgoing: isFromSelf, onTap: { onTranslationAction?() } ) } diff --git a/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift b/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift index f2220031e..035e95c7b 100644 --- a/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift +++ b/MC1Tests/ViewModels/ChatViewModelTranslationTests.swift @@ -232,6 +232,20 @@ struct ChatViewModelTranslationTests { #expect(viewModel.translationSessionRequest == nil) } + @Test + func `outgoing foreign-language message has no translation chrome`() async throws { + let viewModel = ChatViewModel() + let coordinator = ChatCoordinator.makeForTesting() + viewModel.bindCoordinatorForTesting(coordinator) + let message = germanMessage(timestamp: 1000, direction: .outgoing) + viewModel.appendMessageIfNew(message) + await coordinator.buildItemsTask?.value + + let item = try #require(viewModel.items.first { $0.id == message.id }) + #expect(item.translation == nil) + #expect(viewModel.bake.detectedLanguages[message.id] == nil) + } + @Test func `stale needs-download does not consume the newer request`() async throws { let viewModel = ChatViewModel() @@ -283,8 +297,11 @@ struct ChatViewModelTranslationTests { return (viewModel, coordinator, message, CountingMessageTranslator()) } - private func germanMessage(timestamp: UInt32) -> MessageDTO { - makeMessage(timestamp: timestamp, text: german) + private func germanMessage( + timestamp: UInt32, + direction: MessageDirection = .incoming + ) -> MessageDTO { + makeMessage(timestamp: timestamp, text: german, direction: direction) } private func envInputs(preferredLanguageCode: String) -> EnvInputs { @@ -307,7 +324,11 @@ struct ChatViewModelTranslationTests { ) } - private func makeMessage(timestamp: UInt32, text: String) -> MessageDTO { + private func makeMessage( + timestamp: UInt32, + text: String, + direction: MessageDirection = .incoming + ) -> MessageDTO { MessageDTO( id: UUID(), radioID: UUID(), @@ -316,8 +337,8 @@ struct ChatViewModelTranslationTests { text: text, timestamp: timestamp, createdAt: Date(timeIntervalSince1970: TimeInterval(timestamp)), - direction: .incoming, - status: .delivered, + direction: direction, + status: direction == .outgoing ? .sent : .delivered, textType: .plain, ackCode: nil, pathLength: 0, diff --git a/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift b/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift index cc0c240b4..42d965c5b 100644 --- a/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift +++ b/MC1Tests/ViewModels/RoomConversationViewModelTranslationTests.swift @@ -33,6 +33,18 @@ struct RoomConversationViewModelTranslationTests { #expect(without != withOffer) } + @Test + func `from-self foreign-language message has no translation chrome`() throws { + let viewModel = RoomConversationViewModel() + viewModel.preferredLanguageCode = "en" + let message = roomMessage(text: german, isFromSelf: true) + viewModel.appendMessageIfNew(message) + + let row = try #require(viewModel.tiledRows.first) + #expect(row.translation == nil) + #expect(viewModel.detectedLanguages[message.id] == nil) + } + @Test func `detector maps land chrome on the materialized row`() throws { let viewModel = RoomConversationViewModel() @@ -149,13 +161,18 @@ struct RoomConversationViewModelTranslationTests { return viewModel } - private func roomMessage(text: String, timestamp: UInt32 = 100) -> RoomMessageDTO { + private func roomMessage( + text: String, + timestamp: UInt32 = 100, + isFromSelf: Bool = false + ) -> RoomMessageDTO { RoomMessageDTO( sessionID: sessionID, authorKeyPrefix: Data([0xAA]), authorName: "Alice", text: text, - timestamp: timestamp + timestamp: timestamp, + isFromSelf: isFromSelf ) } } From 0763e771ae23170510d84e7047fe7031b585d45f Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:40:50 -0700 Subject: [PATCH 43/47] feat(repeaters): add default scope picker - Regions UI now sets region default, not home - Removing the current default also sends region default - Caption covers originated packets and immediate persist --- MC1/Resources/Generated/L10n.swift | 6 + .../Localization/de.lproj/RemoteNodes.strings | 9 + .../Localization/en.lproj/RemoteNodes.strings | 9 + .../Localization/es.lproj/RemoteNodes.strings | 9 + .../Localization/fr.lproj/RemoteNodes.strings | 9 + .../Localization/it.lproj/RemoteNodes.strings | 9 + .../Localization/nl.lproj/RemoteNodes.strings | 9 + .../Localization/pl.lproj/RemoteNodes.strings | 9 + .../Localization/pt.lproj/RemoteNodes.strings | 9 + .../Localization/ru.lproj/RemoteNodes.strings | 9 + .../Localization/uk.lproj/RemoteNodes.strings | 9 + .../zh-Hans.lproj/RemoteNodes.strings | 9 + .../Repeaters/RepeaterSettingsView.swift | 56 +++- .../Repeaters/RepeaterSettingsViewModel.swift | 86 ++++- MC1/Views/Tools/CLI/CLICompletionEngine.swift | 2 +- ...RepeaterSettingsViewModelRegionTests.swift | 296 +++++++++++++++++- .../Tools/CLI/CLICompletionEngineTests.swift | 2 + 17 files changed, 525 insertions(+), 22 deletions(-) diff --git a/MC1/Resources/Generated/L10n.swift b/MC1/Resources/Generated/L10n.swift index 24385f531..d0b4c5947 100644 --- a/MC1/Resources/Generated/L10n.swift +++ b/MC1/Resources/Generated/L10n.swift @@ -3596,6 +3596,10 @@ public enum L10n { public static let allTraffic = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTraffic", fallback: "Unscoped") /// Location: RepeaterSettingsView.swift - Unscoped region with asterisk display public static let allTrafficWildcard = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.allTrafficWildcard", fallback: "* (Unscoped)") + /// Location: RepeaterSettingsView.swift - Default scope picker label + public static let defaultScope = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.defaultScope", fallback: "Default Scope") + /// Location: RepeaterSettingsView.swift - Caption under default scope picker + public static let defaultScopeCaption = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.defaultScopeCaption", fallback: "Scopes packets this node originates, such as adverts. This setting saves to the repeater immediately.") /// Location: RepeaterSettingsView.swift - Duplicate region name validation public static let duplicate = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.duplicate", fallback: "This region already exists.") /// Location: RepeaterSettingsViewModel.swift - No regions on device @@ -3610,6 +3614,8 @@ public enum L10n { public static func nameTooLong(_ p1: Int) -> String { return L10n.tr("RemoteNodes", "remoteNodes.settings.regions.nameTooLong", p1, fallback: "Region names are limited to %d bytes.") } + /// Location: RepeaterSettingsView.swift - No default scope + public static let noDefault = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.noDefault", fallback: "None") /// Location: RepeaterSettingsViewModel.swift - Region has children error public static let notEmpty = L10n.tr("RemoteNodes", "remoteNodes.settings.regions.notEmpty", fallback: "Remove child regions first") /// Location: RepeaterSettingsView.swift - Region name placeholder diff --git a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings index 72a9715e5..e7b68e6ae 100644 --- a/MC1/Resources/Localization/de.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/de.lproj/RemoteNodes.strings @@ -358,6 +358,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Heimatregion"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Standardbereich"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Keine"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Bestimmt den Bereich für Pakete, die dieser Knoten selbst erzeugt, etwa Ankündigungen. Diese Einstellung wird sofort im Repeater gespeichert."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings index 8e9650c72..4b90a0232 100644 --- a/MC1/Resources/Localization/en.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/en.lproj/RemoteNodes.strings @@ -358,6 +358,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Home Region"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Default Scope"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "None"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Scopes packets this node originates, such as adverts. This setting saves to the repeater immediately."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings index 992e8f105..226e66681 100644 --- a/MC1/Resources/Localization/es.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/es.lproj/RemoteNodes.strings @@ -358,6 +358,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Región local"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Alcance predeterminado"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Ninguno"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Aplica el alcance a los paquetes que origina este nodo, como los anuncios. Este ajuste se guarda inmediatamente en el repetidor."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings index 20ad48924..6e1a38333 100644 --- a/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/fr.lproj/RemoteNodes.strings @@ -358,6 +358,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Région d'origine"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Portée par défaut"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Aucune"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Applique cette portée aux paquets émis par ce nœud, comme les annonces. Ce réglage est enregistré immédiatement sur le répéteur."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/it.lproj/RemoteNodes.strings b/MC1/Resources/Localization/it.lproj/RemoteNodes.strings index 6c97c3a39..24914fb1f 100644 --- a/MC1/Resources/Localization/it.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/it.lproj/RemoteNodes.strings @@ -358,6 +358,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "region principale"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Ambito flood predefinito"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Nessuno"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Applica l'ambito ai pacchetti originati da questo nodo, come gli annunci. Questa impostazione viene salvata subito sul ripetitore."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings index e4ad48377..09cb0fdec 100644 --- a/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/nl.lproj/RemoteNodes.strings @@ -358,6 +358,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Thuisregio"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Standaardregio"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Geen"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Past een regio toe op pakketten die dit knooppunt zelf verzendt, zoals advertenties. Deze instelling wordt meteen op de repeater opgeslagen."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings index 83cf2ee26..7edef5441 100644 --- a/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pl.lproj/RemoteNodes.strings @@ -355,6 +355,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Region domowy"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Domyślny zakres"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Brak"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Stosuje zakres do pakietów pochodzących z tego węzła, np. ogłoszeń. To ustawienie jest od razu zapisywane w przekaźniku."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings index 42ba791c2..81590beaf 100644 --- a/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings @@ -358,6 +358,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Região home"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Âmbito predefinido"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Nenhum"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Aplica o âmbito aos pacotes originados por este nodo, como os anúncios. Esta definição é guardada imediatamente no repetidor."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings index 3866de498..5a5048693 100644 --- a/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/ru.lproj/RemoteNodes.strings @@ -355,6 +355,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Домашний регион"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Область по умолчанию"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Нет"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Задаёт область пакетам, исходящим от этого узла, например объявлениям. Этот параметр сразу сохраняется на ретрансляторе."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings index 1a8a387d6..9c82c77eb 100644 --- a/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/uk.lproj/RemoteNodes.strings @@ -355,6 +355,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Домашній регіон"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "Область за замовчуванням"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "Немає"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "Застосовує область до пакетів, що походять від цього вузла, наприклад оголошень. Це налаштування одразу зберігається на ретрансляторі."; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings index ca7a72847..e54ed09be 100644 --- a/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/zh-Hans.lproj/RemoteNodes.strings @@ -358,6 +358,15 @@ /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "主区域"; +/* Location: RepeaterSettingsView.swift - Default scope picker label */ +"remoteNodes.settings.regions.defaultScope" = "默认泛洪范围"; + +/* Location: RepeaterSettingsView.swift - No default scope */ +"remoteNodes.settings.regions.noDefault" = "无"; + +/* Location: RepeaterSettingsView.swift - Caption under default scope picker */ +"remoteNodes.settings.regions.defaultScopeCaption" = "为此节点发起的数据包(例如广播)设置范围。此设置会立即保存到转发节点。"; + /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift index 1b4c2f0b0..9e76de5d1 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsView.swift @@ -356,6 +356,17 @@ private struct RegionsSection: View { : region.name } + private var defaultScopePickerNames: [String] { + var names = sortedRegions.filter { !$0.isWildcard }.map(\.name) + if let current = viewModel.defaultScopeName, + current != RepeaterSettingsViewModel.wildcardName, + !current.isEmpty, + !names.contains(current) { + names.append(current) + } + return names + } + var body: some View { ExpandableSettingsSection( title: L10n.RemoteNodes.RemoteNodes.Settings.regions, @@ -372,24 +383,41 @@ private struct RegionsSection: View { .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) } + if viewModel.defaultScopeLoaded { + Picker( + L10n.RemoteNodes.RemoteNodes.Settings.Regions.defaultScope, + selection: Binding( + get: { viewModel.defaultScopeName }, + set: { newValue in + Task { await viewModel.setDefaultScope(name: newValue) } + } + ) + ) { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.noDefault) + .tag(String?.none) + ForEach(defaultScopePickerNames, id: \.self) { name in + Text(name) + .tag(Optional(name)) + } } - )) { - ForEach(sortedRegions) { region in - Text(displayName(for: region)) - .tag(region.name) + .pickerStyle(.menu) + .tint(.primary) + .disabled(viewModel.isLoadingRegions || viewModel.helper.isApplying) + } else { + HStack { + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.defaultScope) + Spacer() + SettingsLoadPlaceholder( + isLoading: viewModel.isLoadingRegions, + hasError: !viewModel.isLoadingRegions + ) } } - .pickerStyle(.menu) - .tint(.primary) + + Text(L10n.RemoteNodes.RemoteNodes.Settings.Regions.defaultScopeCaption) + .font(.caption) + .foregroundStyle(.secondary) } // Region list with flood toggles diff --git a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift index 1100dea7f..dab7c1870 100644 --- a/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift +++ b/MC1/Views/RemoteNodes/Repeaters/RepeaterSettingsViewModel.swift @@ -41,6 +41,12 @@ final class RepeaterSettingsViewModel { // MARK: - Repeater-Only: Region Settings nonisolated static let wildcardName = "*" + /// CLI argument when default scope is unset (`region default `). + private static let firmwareNullToken = "" + /// GET substring. SET replies use `defaultScopeSetReplyMarker`, which also matches this. + private static let defaultScopeReplyMarker = "default scope is" + private static let defaultScopeSetReplyMarker = "default scope is now" + var regions: [RepeaterRegionEntry] = [] private var originalRegions: [RepeaterRegionEntry]? var isLoadingRegions = false @@ -51,6 +57,10 @@ final class RepeaterSettingsViewModel { var hasUnsavedRegionChanges = false var regionsSaveSuccess = false + /// Unset when nil. Scopes flood traffic this node originates, not which regions it repeats. + var defaultScopeName: String? + /// False until a `region default` reply parses. Distinct from `defaultScopeName == nil`. + var defaultScopeLoaded = false // MARK: - Expansion State (repeater-only sections) @@ -317,6 +327,20 @@ final class RepeaterSettingsViewModel { let parsed = Self.parseRegionTree(treeResponse) regions = parsed originalRegions = parsed + do { + let defaultReply = try await helper.sendAndWait( + "region default", + timeout: .seconds(10), + rawMatching: true + ) + if let parsed = Self.parseDefaultScopeReply(defaultReply) { + applyParsedDefaultScope(parsed) + } else { + logger.warning("Unparsed region default reply: \(defaultReply)") + } + } catch { + logger.warning("Failed to fetch default scope: \(error)") + } } catch { if case RemoteNodeError.timeout = error { regionsError = true @@ -364,6 +388,39 @@ final class RepeaterSettingsViewModel { return entries } + /// Unset (`` or `*`) versus a named region. + enum ParsedDefaultScope: Equatable { + case cleared + case named(String) + } + + /// Nil is an unparsed reply, not an unset scope. + static func parseDefaultScopeReply(_ response: String) -> ParsedDefaultScope? { + let lines = response.split(separator: "\n", omittingEmptySubsequences: true) + guard let last = lines.last else { return nil } + var line = last.trimmingCharacters(in: .whitespaces) + if line.hasPrefix(">") { + line = String(line.dropFirst()).trimmingCharacters(in: .whitespaces) + } + guard line.localizedCaseInsensitiveContains(defaultScopeReplyMarker) else { return nil } + + let tokens = line.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + guard let lastToken = tokens.last else { return nil } + // Firmware treats a default of `*` as unset, same as ``. + if lastToken == firmwareNullToken || lastToken == wildcardName { return .cleared } + return .named(lastToken) + } + + private func applyParsedDefaultScope(_ parsed: ParsedDefaultScope) { + switch parsed { + case .cleared: + defaultScopeName = nil + case let .named(name): + defaultScopeName = name + } + defaultScopeLoaded = true + } + func toggleRegionFlood(name: String) async { guard let index = regions.firstIndex(where: { $0.name == name }) else { return } let currentlyAllowed = regions[index].floodAllowed @@ -387,19 +444,24 @@ final class RepeaterSettingsViewModel { helper.isApplying = false } - func setHomeRegion(name: String) async { - let command = "region home \(name)" + func setDefaultScope(name: String?) async { + if name == Self.wildcardName { return } + if name == defaultScopeName { return } + + let argument = name ?? Self.firmwareNullToken + let command = "region default \(argument)" 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) + if response.contains(Self.defaultScopeSetReplyMarker) { + defaultScopeName = name + defaultScopeLoaded = true + if let name, let index = regions.firstIndex(where: { $0.name == name }) { + regions[index].floodAllowed = true } - hasUnsavedRegionChanges = true } else { helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion } @@ -446,12 +508,24 @@ final class RepeaterSettingsViewModel { func removeRegion(name: String) async { helper.isApplying = true helper.errorMessage = nil + let wasDefault = defaultScopeName == name do { let response = try await helper.sendAndWait("region remove \(name)") if case .ok = CLIResponse.parse(response) { regions.removeAll { $0.name == name } hasUnsavedRegionChanges = true + if wasDefault { + let clearReply = try await helper.sendAndWait( + "region default \(Self.firmwareNullToken)", + rawMatching: true + ) + if clearReply.contains(Self.defaultScopeSetReplyMarker) { + defaultScopeName = nil + } else { + helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion + } + } } else if response.contains("not empty") { helper.errorMessage = L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty } else { diff --git a/MC1/Views/Tools/CLI/CLICompletionEngine.swift b/MC1/Views/Tools/CLI/CLICompletionEngine.swift index f733d25e5..375d9f01b 100644 --- a/MC1/Views/Tools/CLI/CLICompletionEngine.swift +++ b/MC1/Views/Tools/CLI/CLICompletionEngine.swift @@ -58,7 +58,7 @@ final class CLICompletionEngine { /// Per MeshCore CLI Reference - region subcommands private static let regionSubcommands = [ - "load", "get", "put", "remove", "allowf", "denyf", "home", "save", "list" + "load", "get", "put", "remove", "allowf", "denyf", "home", "default", "save", "list" ] /// Per MeshCore CLI Reference - gps subcommands diff --git a/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift b/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift index 2696598c9..9599dc014 100644 --- a/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift +++ b/MC1Tests/ViewModels/RepeaterSettingsViewModelRegionTests.swift @@ -11,10 +11,13 @@ struct RepeaterSettingsRegionTests { final class CommandRecorder { private(set) var commands: [String] = [] var reply: String = "OK - (flood allowed)" + var repliesByCommand: [String: String] = [:] + var errorsByCommand: [String: Error] = [:] func send(_ id: UUID, _ command: String, _ timeout: Duration) async throws -> String { commands.append(command) - return reply + if let error = errorsByCommand[command] { throw error } + return repliesByCommand[command] ?? reply } } @@ -101,3 +104,294 @@ struct RepeaterSettingsRegionTests { #expect(!viewModel.hasUnsavedRegionChanges) } } + +@Suite("RepeaterSettingsViewModel default scope") +@MainActor +struct RepeaterSettingsDefaultScopeTests { + private func makeViewModel(recorder: RepeaterSettingsRegionTests.CommandRecorder) -> RepeaterSettingsViewModel { + let viewModel = RepeaterSettingsViewModel() + viewModel.helper.configure( + session: RemoteNodeSessionDTO( + radioID: UUID(), + publicKey: Data(repeating: 0x42, count: 32), + name: "Test Repeater", + role: .repeater, + isConnected: true, + permissionLevel: .admin + ), + sendCommand: recorder.send, + sendRawCommand: recorder.send + ) + return viewModel + } + + @Test + func `parseDefaultScopeReply reads get and set lines`() { + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply(" default scope is ") + == RepeaterSettingsViewModel.ParsedDefaultScope.cleared + ) + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply(" default scope is duckburg") + == .named("duckburg") + ) + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply("> default scope is now duckburg") + == .named("duckburg") + ) + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply(" default scope is now ") + == RepeaterSettingsViewModel.ParsedDefaultScope.cleared + ) + #expect(RepeaterSettingsViewModel.parseDefaultScopeReply("OK") == nil) + #expect( + RepeaterSettingsViewModel.parseDefaultScopeReply(" default scope is *") + == RepeaterSettingsViewModel.ParsedDefaultScope.cleared + ) + } + + @Test + func `fetchRegions reads default scope after the tree`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F\n duckburg F", + "region default": " default scope is duckburg" + ] + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.fetchRegions() + + #expect(recorder.commands == ["region", "region default"]) + #expect(viewModel.regions.map(\.name) == ["*", "duckburg"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `fetchRegions treats firmware null as no default scope`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F", + "region default": " default scope is " + ] + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.fetchRegions() + + #expect(viewModel.defaultScopeName == nil) + #expect(viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `unparsed default reply does not fail the regions section`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F", + "region default": "OK" + ] + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.fetchRegions() + + #expect(viewModel.regions.map(\.name) == ["*"]) + #expect(viewModel.defaultScopeName == nil) + #expect(!viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `unparsed default reply keeps the previous default scope`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F\n duckburg F", + "region default": " default scope is duckburg" + ] + let viewModel = makeViewModel(recorder: recorder) + await viewModel.fetchRegions() + + recorder.repliesByCommand["region default"] = "OK" + await viewModel.fetchRegions() + + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `default-scope timeout keeps the previous value and does not fail regions`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region": "* F\n duckburg F", + "region default": " default scope is duckburg" + ] + let viewModel = makeViewModel(recorder: recorder) + await viewModel.fetchRegions() + + recorder.errorsByCommand["region default"] = RemoteNodeError.timeout + await viewModel.fetchRegions() + + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.defaultScopeLoaded) + #expect(!viewModel.regionsError) + } + + @Test + func `setDefaultScope sends region default and does not mark unsaved`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = " default scope is now duckburg" + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "duckburg", floodAllowed: false, isHome: false) + ] + + await viewModel.setDefaultScope(name: "duckburg") + + #expect(recorder.commands == ["region default duckburg"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(!viewModel.hasUnsavedRegionChanges) + #expect(viewModel.regions.first?.floodAllowed == true) + #expect(viewModel.helper.errorMessage == nil) + } + + @Test + func `setDefaultScope nil sends firmware null token`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = " default scope is now " + let viewModel = makeViewModel(recorder: recorder) + viewModel.defaultScopeName = "duckburg" + + await viewModel.setDefaultScope(name: nil) + + #expect(recorder.commands == ["region default "]) + #expect(viewModel.defaultScopeName == nil) + #expect(!viewModel.hasUnsavedRegionChanges) + } + + @Test + func `setDefaultScope non-matching reply leaves the current value`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = "Err - unknown region" + let viewModel = makeViewModel(recorder: recorder) + viewModel.defaultScopeName = "old" + + await viewModel.setDefaultScope(name: "nope") + + #expect(viewModel.defaultScopeName == "old") + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion) + } + + @Test + func `setDefaultScope does not send wildcard`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + + await viewModel.setDefaultScope(name: RepeaterSettingsViewModel.wildcardName) + + #expect(recorder.commands.isEmpty) + #expect(viewModel.defaultScopeName == nil) + } + + @Test + func `setDefaultScope same value is a no-op`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + let viewModel = makeViewModel(recorder: recorder) + viewModel.defaultScopeName = "duckburg" + + await viewModel.setDefaultScope(name: "duckburg") + + #expect(recorder.commands.isEmpty) + } + + @Test + func `removeRegion of current default sends remove then default clear`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region remove duckburg": "OK", + "region default ": " default scope is now " + ] + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "*", floodAllowed: true, isHome: false), + RepeaterRegionEntry(name: "duckburg", floodAllowed: true, isHome: false) + ] + viewModel.defaultScopeName = "duckburg" + viewModel.defaultScopeLoaded = true + + await viewModel.removeRegion(name: "duckburg") + + #expect(recorder.commands == ["region remove duckburg", "region default "]) + #expect(viewModel.regions.map(\.name) == ["*"]) + #expect(viewModel.defaultScopeName == nil) + #expect(viewModel.hasUnsavedRegionChanges) + #expect(viewModel.helper.errorMessage == nil) + #expect(!viewModel.helper.isApplying) + } + + @Test + func `removeRegion of current default keeps the name when trailing clear fails`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.repliesByCommand = [ + "region remove duckburg": "OK", + "region default ": "Err - save failed" + ] + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "*", floodAllowed: true, isHome: false), + RepeaterRegionEntry(name: "duckburg", floodAllowed: true, isHome: false) + ] + viewModel.defaultScopeName = "duckburg" + viewModel.defaultScopeLoaded = true + + await viewModel.removeRegion(name: "duckburg") + + #expect(recorder.commands == ["region remove duckburg", "region default "]) + #expect(viewModel.regions.map(\.name) == ["*"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.defaultScopeLoaded) + #expect(viewModel.hasUnsavedRegionChanges) + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.unknownRegion) + #expect(!viewModel.helper.isApplying) + } + + @Test + func `removeRegion of current default does not send default clear when remove is rejected`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = "Err - not empty" + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "*", floodAllowed: true, isHome: false), + RepeaterRegionEntry(name: "duckburg", floodAllowed: true, isHome: false) + ] + viewModel.defaultScopeName = "duckburg" + viewModel.defaultScopeLoaded = true + + await viewModel.removeRegion(name: "duckburg") + + #expect(recorder.commands == ["region remove duckburg"]) + #expect(viewModel.regions.map(\.name) == ["*", "duckburg"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(!viewModel.hasUnsavedRegionChanges) + #expect(viewModel.helper.errorMessage == L10n.RemoteNodes.RemoteNodes.Settings.Regions.notEmpty) + } + + @Test + func `removeRegion of a different region does not send region default`() async { + let recorder = RepeaterSettingsRegionTests.CommandRecorder() + recorder.reply = "OK" + let viewModel = makeViewModel(recorder: recorder) + viewModel.regions = [ + RepeaterRegionEntry(name: "duckburg", floodAllowed: true, isHome: false), + RepeaterRegionEntry(name: "goosetown", floodAllowed: true, isHome: false) + ] + viewModel.defaultScopeName = "duckburg" + viewModel.defaultScopeLoaded = true + + await viewModel.removeRegion(name: "goosetown") + + #expect(recorder.commands == ["region remove goosetown"]) + #expect(viewModel.defaultScopeName == "duckburg") + #expect(viewModel.regions.map(\.name) == ["duckburg"]) + } +} diff --git a/MC1Tests/Views/Tools/CLI/CLICompletionEngineTests.swift b/MC1Tests/Views/Tools/CLI/CLICompletionEngineTests.swift index c72f6cca8..59115ab7d 100644 --- a/MC1Tests/Views/Tools/CLI/CLICompletionEngineTests.swift +++ b/MC1Tests/Views/Tools/CLI/CLICompletionEngineTests.swift @@ -99,6 +99,8 @@ struct CLICompletionEngineTests { #expect(suggestions.contains("load")) #expect(suggestions.contains("get")) #expect(suggestions.contains("put")) + #expect(suggestions.contains("home")) + #expect(suggestions.contains("default")) #expect(suggestions.contains("save")) } From 93b50ae888c2e2999279b2501d35a104424abfed Mon Sep 17 00:00:00 2001 From: zadoke Date: Fri, 28 Aug 2026 21:45:29 +0100 Subject: [PATCH 44/47] fix(l10n): revise pt-PT translation --- .../Localization/pt.lproj/Chats.strings | 112 ++++----- .../Localization/pt.lproj/Contacts.strings | 234 +++++++++--------- .../Localization/pt.lproj/Localizable.strings | 64 ++--- .../Localization/pt.lproj/Map.strings | 14 +- .../Localization/pt.lproj/Onboarding.strings | 30 +-- .../Localization/pt.lproj/RemoteNodes.strings | 72 +++--- .../Localization/pt.lproj/Settings.strings | 162 ++++++------ .../pt.lproj/Settings.stringsdict | 8 +- .../Localization/pt.lproj/Tools.strings | 66 ++--- .../Localization/pt.lproj/WhatsNew.strings | 8 +- 10 files changed, 385 insertions(+), 385 deletions(-) diff --git a/MC1/Resources/Localization/pt.lproj/Chats.strings b/MC1/Resources/Localization/pt.lproj/Chats.strings index 5b757d974..64f618c24 100644 --- a/MC1/Resources/Localization/pt.lproj/Chats.strings +++ b/MC1/Resources/Localization/pt.lproj/Chats.strings @@ -9,7 +9,7 @@ // MARK: - Navigation & Titles /* Location: ChatsView.swift - Navigation title for main chat list */ -"chats.title" = "Chats"; +"chats.title" = "Conversas"; /* Location: ChatsView.swift - Search placeholder */ "chats.search.placeholder" = "Pesquisar conversas"; @@ -27,7 +27,7 @@ "chats.filter.unread" = "Por ler"; /* Location: ChatsView.swift - Filter option for direct messages */ -"chats.filter.directMessages" = "DMs"; +"chats.filter.directMessages" = "Mensagens diretas"; /* Location: ChatsView.swift - Filter option for channels */ "chats.filter.channels" = "Canais"; @@ -38,7 +38,7 @@ // MARK: - Compose Menu /* Location: ChatsView.swift - Button to start a new direct chat */ -"chats.compose.newChat" = "Novo chat"; +"chats.compose.newChat" = "Nova conversa"; /* Location: ChatsView.swift - Button to create or join a channel */ "chats.compose.newChannel" = "Novo canal"; @@ -52,7 +52,7 @@ "chats.emptyState.noConversations.title" = "Nenhuma conversa"; /* Location: ChatsView.swift - Description when no conversations exist */ -"chats.emptyState.noConversations.description" = "Inicie uma conversa a partir de Contactos"; +"chats.emptyState.noConversations.description" = "Inicie uma conversa a partir do separador Nós"; /* Location: ChatsView.swift - Title when no unread messages */ "chats.emptyState.noUnread.title" = "Sem mensagens por ler"; @@ -61,22 +61,22 @@ "chats.emptyState.noUnread.description" = "Tudo lido"; /* Location: ChatsView.swift - Title when no direct messages */ -"chats.emptyState.noDirectMessages.title" = "Sem DMs"; +"chats.emptyState.noDirectMessages.title" = "Sem mensagens diretas"; /* Location: ChatsView.swift - Description when no direct messages */ -"chats.emptyState.noDirectMessages.description" = "Inicie um chat a partir de Contactos"; +"chats.emptyState.noDirectMessages.description" = "Inicie uma conversa a partir do separador Nós"; /* Location: ChatsView.swift - Title when no channels */ "chats.emptyState.noChannels.title" = "Nenhum canal"; /* Location: ChatsView.swift - Description when no channels */ -"chats.emptyState.noChannels.description" = "Aderir ou criar um canal"; +"chats.emptyState.noChannels.description" = "Adira ou crie um canal"; /* Location: ChatsView.swift - Title when no rooms exist */ "chats.emptyState.noRooms.title" = "Nenhuma sala"; /* Location: ChatsView.swift - Description when no rooms exist */ -"chats.emptyState.noRooms.description" = "Entre numa sala a partir de Contactos"; +"chats.emptyState.noRooms.description" = "Entre numa sala a partir do separador Nós"; /* Location: ChatsView.swift - Split view placeholder when no conversation selected */ "chats.emptyState.selectConversation" = "Selecione uma conversa"; @@ -123,7 +123,7 @@ "chats.alert.unableToSend.message" = "Certifique-se de que há uma ligação ao dispositivo e tente novamente."; /* Location: ChatConversationView.swift - Connection status for flood routed contacts */ -"chats.connectionStatus.floodRouting" = "Encaminhamento Flood"; +"chats.connectionStatus.floodRouting" = "Encaminhamento por difusão"; /* Location: ChatConversationView.swift - Connection status format for direct path - %d is hop count */ "chats.connectionStatus.direct" = "Direto • %d saltos"; @@ -141,7 +141,7 @@ "chats.contactInfo.hasLocation" = "Tem localização"; /* Location: ChatConversationView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "DM"; +"chats.input.placeholder.directMessage" = "Mensagem direta"; // MARK: - Channel Chat View @@ -187,7 +187,7 @@ /* Location: ChannelInfoSheet.swift - Label for channel slot */ /* Location: ChannelInfoSheet.swift - Label for last message date */ /* Location: ChannelInfoSheet.swift - QR code instruction text */ -"chats.channelInfo.scanToJoin" = "Digitalize para aderir a este canal"; +"chats.channelInfo.scanToJoin" = "Leia o código QR para aderir a este canal"; /* Location: ChannelInfoSheet.swift - Section header for QR sharing */ "chats.channelInfo.shareChannel" = "Partilhar canal"; @@ -264,10 +264,10 @@ "chats.channelOptions.joinPrivate.description" = "Introduza o nome do canal e a chave secreta"; /* Location: ChannelOptionsSheet.swift - Scan QR code option title */ -"chats.channelOptions.scanQR.title" = "Digitalizar um código QR"; +"chats.channelOptions.scanQR.title" = "Ler um código QR"; /* Location: ChannelOptionsSheet.swift - Scan QR code option description */ -"chats.channelOptions.scanQR.description" = "Aderir a um canal ao digitalizar o código QR"; +"chats.channelOptions.scanQR.description" = "Aderir a um canal lendo o código QR"; /* Location: ChannelOptionsSheet.swift - Section header for private channels */ "chats.channelOptions.section.private" = "Canais privados"; @@ -302,7 +302,7 @@ "chats.newChat.emptyState.description" = "Os contactos aparecem quando forem descobertos"; /* Location: NewChatView.swift - Navigation title */ -"chats.newChat.title" = "Novo chat"; +"chats.newChat.title" = "Nova conversa"; /* Location: NewChatView.swift - Search placeholder */ "chats.newChat.search.placeholder" = "Pesquisar contactos"; @@ -480,7 +480,7 @@ "chats.joinHashtag.section.header" = "Canal hashtag"; /* Location: JoinHashtagChannelView.swift - Footer explaining hashtag channels */ -"chats.joinHashtag.footer" = "Os canais hashtag são públicos. Qualquer pessoa pode aderir ao introduzir o mesmo nome. Só são permitidas letras minúsculas, números e hífenes."; +"chats.joinHashtag.footer" = "Os canais hashtag são públicos. Qualquer pessoa pode aderir introduzindo o mesmo nome. Só são permitidas letras minúsculas, números e hífenes."; /* Location: JoinHashtagChannelView.swift - Description about encryption */ "chats.joinHashtag.encryptionDescription" = "O nome do canal é usado para gerar a chave de encriptação. Qualquer pessoa com o mesmo nome pode ler as mensagens."; @@ -501,7 +501,7 @@ "chats.joinHashtag.alreadyJoined" = "Já aderiu"; /* Location: JoinHashtagChannelView.swift - Accessibility label for already joined */ -"chats.joinHashtag.alreadyJoinedAccessibility" = "Canal já aderido"; +"chats.joinHashtag.alreadyJoinedAccessibility" = "Já aderiu a este canal"; /* Location: JoinHashtagChannelView.swift - Navigation title */ "chats.joinHashtag.title" = "Aderir a canal hashtag"; @@ -527,7 +527,7 @@ "chats.joinFromMessage.noSlots.description" = "Todos os slots de canal estão cheios. Remova um canal existente para aderir a %@."; /* Location: JoinHashtagFromMessageView.swift - Description of hashtag channels */ -"chats.joinFromMessage.description" = "Os canais hashtag são públicos. Qualquer pessoa pode aderir ao introduzir o mesmo nome."; +"chats.joinFromMessage.description" = "Os canais hashtag são públicos. Qualquer pessoa pode aderir introduzindo o mesmo nome."; /* Location: JoinHashtagFromMessageView.swift - Button to join channel - %@ is channel name */ "chats.joinFromMessage.joinButton" = "Aderir a %@"; @@ -550,25 +550,25 @@ // MARK: - Scan Channel QR View /* Location: ScanChannelQRView.swift - Navigation title */ -"chats.scanQR.title" = "Digitalizar código QR"; +"chats.scanQR.title" = "Ler código QR"; /* Location: ScanChannelQRView.swift - Error when scanner not available */ "chats.scanQR.notAvailable.title" = "Scanner indisponível"; /* Location: ScanChannelQRView.swift - Error description when scanner not available */ -"chats.scanQR.notAvailable.description" = "A digitalização de QR não é suportada neste dispositivo"; +"chats.scanQR.notAvailable.description" = "A leitura de códigos QR não é suportada neste dispositivo"; /* Location: ScanChannelQRView.swift - Instruction to point camera */ "chats.scanQR.instruction" = "Aponte a câmara para o código QR de um canal"; /* Location: ScanChannelQRView.swift - Button to scan again */ -"chats.scanQR.scanAgain" = "Digitalizar novamente"; +"chats.scanQR.scanAgain" = "Ler novamente"; /* Location: ScanChannelQRView.swift - Camera permission denied title */ "chats.scanQR.permissionDenied.title" = "É necessário acesso à câmara"; /* Location: ScanChannelQRView.swift - Camera permission denied message */ -"chats.scanQR.permissionDenied.message" = "Ative o acesso à câmara em Definições para digitalizar códigos QR."; +"chats.scanQR.permissionDenied.message" = "Ative o acesso à câmara em Definições para ler códigos QR."; /* Location: ScanChannelQRView.swift - Button to open settings */ "chats.scanQR.openSettings" = "Abrir Definições"; @@ -607,7 +607,7 @@ "chats.message.translatedAccessibility" = "Traduzido: %@"; /* Location: UnifiedMessageBubble.swift - Context menu action to view repeat details */ -"chats.message.action.repeatDetails" = "Detalhes da repetição"; +"chats.message.action.repeatDetails" = "Detalhes da retransmissão"; /* Location: UnifiedMessageBubble.swift - Context menu action to send again */ "chats.message.action.sendAgain" = "Enviar novamente"; @@ -616,10 +616,10 @@ "chats.message.info.heardRepeats" = "Receção: %d %@"; /* Location: UnifiedMessageBubble.swift - Singular form of repeat */ -"chats.message.repeat.singular" = "repetição"; +"chats.message.repeat.singular" = "retransmissão"; /* Location: UnifiedMessageBubble.swift - Plural form of repeats */ -"chats.message.repeat.plural" = "repetições"; +"chats.message.repeat.plural" = "retransmissões"; /* Location: UnifiedMessageBubble.swift - Context menu text showing sent time - %@ is formatted date */ "chats.message.info.sent" = "Enviado: %@"; @@ -628,13 +628,13 @@ "chats.message.info.roundTrip" = "Ida e volta: %dms"; /* Location: UnifiedMessageBubble.swift - Context menu action to view path */ -"chats.message.action.viewPath" = "Ver caminho"; +"chats.message.action.viewPath" = "Ver rota"; /* Location: UnifiedMessageBubble.swift - Context menu text showing hop count - %@ is count or "Direct" */ "chats.message.info.hops" = "Saltos: %@"; /* Location: ActionsDetailsSection.swift - Info row showing the path hash size in bytes - %d is 1, 2, or 3 */ -"chats.message.info.pathHash" = "Hash do caminho: %d byte"; +"chats.message.info.pathHash" = "Hash da rota: %d byte"; /* Location: UnifiedMessageBubble.swift - Indicator that timestamp was adjusted */ "chats.message.info.adjusted" = "(ajustado)"; @@ -675,7 +675,7 @@ "chats.message.action.blockSender" = "Bloquear remetente"; /* Location: MessageActionsSheet.swift - Purpose: Action to start a DM with the channel sender */ -"chats.message.action.sendDM" = "Enviar DM"; +"chats.message.action.sendDM" = "Enviar mensagem direta"; /* Location: UnifiedMessageBubble.swift - VoiceOver action to retry a failed send */ "chats.message.action.retry" = "Tentar novamente"; @@ -753,12 +753,12 @@ /* Location: UnifiedMessageBubble.swift - Path footer for direct messages (no hops) */ "chats.message.path.direct" = "Direto"; /* Routing strategy where the radio rebroadcasts the message to every neighbor (no specific path). UI displays this as a label in the per-message details sheet. Prefer a localized term over the English loanword if one exists in your locale's networking terminology. */ -"chats.message.path.flood" = "Flood"; +"chats.message.path.flood" = "Difusão"; /* Location: UnifiedMessageBubble.swift - Fallback path showing hop count - %d is number */ /* Location: MessagePathFormatter.swift - Fallback when path nodes unavailable */ /* Location: UnifiedMessageBubble.swift - Accessibility label for routing path - %@ is the path */ -"chats.message.path.accessibilityLabel" = "Caminho de encaminhamento: %@"; +"chats.message.path.accessibilityLabel" = "Rota de encaminhamento: %@"; /* Location: UnifiedMessageBubble.swift - Accessibility label for hop count display - %d is count */ "chats.message.hopCount.accessibilityLabel" = "Número de saltos: %d"; @@ -773,13 +773,13 @@ "chats.message.region.ambiguous.possibleMatch" = "Várias regiões correspondentes"; /* Location: FallbackMatchIndicatorView.swift - Accessibility hint for region multi-match */ -"chats.message.region.ambiguous.possibleMatchHint" = "Mais de uma região da lista corresponde ao código de transporte deste pacote. A aplicação não consegue identificar qual foi usada."; +"chats.message.region.ambiguous.possibleMatchHint" = "Mais do que uma região da lista corresponde ao código de transporte deste pacote. A app não consegue identificar qual foi usada."; /* Location: FallbackMatchIndicatorView.swift - Popover title for region multi-match */ "chats.message.region.ambiguous.popoverTitle" = "Várias regiões correspondentes"; /* Location: FallbackMatchIndicatorView.swift - Popover body; %@ is newline-prefixed list of candidate names */ -"chats.message.region.ambiguous.popoverBody" = "Mais de uma região da lista corresponde ao código de transporte deste pacote. A aplicação não consegue identificar qual foi usada.%@"; +"chats.message.region.ambiguous.popoverBody" = "Mais do que uma região da lista corresponde ao código de transporte deste pacote. A app não consegue identificar qual foi usada.%@"; /* Location: BubbleFooterRow.swift - Accessibility label for the raw send time footer - %@ is the time */ "chats.message.sendTime.accessibilityLabel" = "Hora de envio: %@"; @@ -862,7 +862,7 @@ "chats.share.contact" = "Partilhar contacto"; /* Location: ChatShareMenu.swift - Share menu action to share the user's own node info */ -"chats.share.myInfo" = "Partilhar as minhas informações"; +"chats.share.myInfo" = "Partilhar os meus dados"; // MARK: - Contact Picker @@ -878,26 +878,26 @@ // MARK: - Message Path Sheet /* Location: MessagePathSheet.swift - Empty state title */ -"chats.path.unavailable.title" = "Caminho indisponível"; +"chats.path.unavailable.title" = "Rota indisponível"; /* Location: MessagePathSheet.swift - Empty state description */ -"chats.path.unavailable.description" = "Os dados do caminho não estão disponíveis para esta mensagem"; +"chats.path.unavailable.description" = "Os dados da rota não estão disponíveis para esta mensagem"; /* Location: MessagePathSheet.swift - Button to copy path */ -"chats.path.copyButton" = "Copiar caminho"; +"chats.path.copyButton" = "Copiar rota"; /* Location: MessagePathSheet.swift - Accessibility label for copy button */ -"chats.path.copyAccessibility" = "Copiar caminho para a área de transferência"; +"chats.path.copyAccessibility" = "Copiar rota para a área de transferência"; /* Location: MessagePathSheet.swift - Accessibility hint for copy button */ -"chats.path.copyHint" = "Copia os IDs dos nodos como valores hexadecimais"; +"chats.path.copyHint" = "Copia os IDs dos nós como valores hexadecimais"; /* Location: MessagePathSheet.swift - Section header for path */ /* Location: MessagePathMapView.swift - Path map button and sheet navigation title */ -"chats.path.map" = "Mapa do caminho"; +"chats.path.map" = "Mapa da rota"; /* Location: MessagePathMapView.swift - Center on path map control accessibility label */ -"chats.path.centerOnPath" = "Centrar no caminho"; +"chats.path.centerOnPath" = "Centrar na rota"; // MARK: - Path Hop Row View @@ -911,7 +911,7 @@ "chats.path.hop.possibleMatchTitle" = "Possível correspondência"; /* Location: FallbackMatchIndicatorView.swift - Explanation of what a possible match means */ -"chats.path.hop.possibleMatchExplanation" = "Vários nodos partilham este prefixo. O nome apresentado pode não estar correto."; +"chats.path.hop.possibleMatchExplanation" = "Vários nós partilham este prefixo. O nome apresentado pode não estar correto."; /* Location: PathHopRowView.swift - Label for sender (first hop) */ "chats.path.hop.sender" = "Remetente"; @@ -920,13 +920,13 @@ "chats.path.hop.number" = "Salto %d"; /* Location: PathHopRowView.swift - Accessibility value format for last hop - %@ is quality, %@ is SNR */ -"chats.path.hop.signalQuality" = "Qualidade do sinal: %@, SNR %@ dB"; +"chats.path.hop.signalQuality" = "Sinal: %@, SNR %@ dB"; /* Location: PathHopRowView.swift - Accessibility value format for non-last hops - %@ is hex ID */ -"chats.path.hop.nodeId" = "ID do nodo: %@"; +"chats.path.hop.nodeId" = "ID do nó: %@"; /* Location: PathHopRowView.swift - Unknown signal quality */ -"chats.path.hop.signalUnknown" = "Desconhecida"; +"chats.path.hop.signalUnknown" = "Desconhecido"; /* Location: PathHopRowView.swift - Label for receiver (your device) */ "chats.path.receiver.label" = "Destinatário"; @@ -937,16 +937,16 @@ // MARK: - Repeat Details Sheet /* Location: RepeatDetailsSheet.swift - Empty state title */ -"chats.repeats.emptyState.title" = "Ainda sem repetições"; +"chats.repeats.emptyState.title" = "Ainda sem retransmissões"; /* Location: RepeatDetailsSheet.swift - Empty state description */ -"chats.repeats.emptyState.description" = "As repetições aparecem aqui à medida que a mensagem se propaga pela mesh"; +"chats.repeats.emptyState.description" = "As retransmissões aparecem aqui à medida que a mensagem se propaga pela mesh"; /* Location: RepeatDetailsSheet.swift - Navigation title */ // MARK: - Repeat Row View /* Location: RepeatRowView.swift - Accessibility label format - %@ is repeater name */ -"chats.repeats.row.accessibility" = "Repetição de %@"; +"chats.repeats.row.accessibility" = "Retransmissão de %@"; /* Location: RepeatRowView.swift - Accessibility value format - %@ is quality, %@ is SNR, %@ is RSSI */ "chats.repeats.row.accessibilityValue" = "Sinal %@, SNR %@, RSSI %@"; @@ -1026,7 +1026,7 @@ "chats.common.cancel" = "Cancelar"; /* Location: Various - Done button (use L10n.Localizable.Common.done) */ -"chats.common.done" = "OK"; +"chats.common.done" = "Concluído"; // MARK: - Reaction Details Sheet @@ -1085,7 +1085,7 @@ "chats.tip.deviceMenu.title" = "Menu do dispositivo"; /* Location: DeviceMenuTip.swift - Purpose: Tip message explaining device menu features */ -"chats.tip.deviceMenu.message" = "Faça a gestão da ligação, envie Adverts e consulte a bateria. O rádio permanece ligado mesmo depois de sair da aplicação à força. Toque em Desligar para interromper a ligação."; +"chats.tip.deviceMenu.message" = "Faça a gestão da ligação, envie Adverts e consulte a bateria. O rádio permanece ligado mesmo depois de forçar o encerramento da app. Toque em Desligar para interromper a ligação."; // MARK: - Block Sender Sheet @@ -1093,7 +1093,7 @@ "chats.blockSender.title" = "Bloquear \"%@\""; /* Location: BlockSenderSheet.swift - Purpose: Explanation of name-based blocking limitation */ -"chats.blockSender.limitation" = "As mensagens de canal não incluem a identidade do remetente. Este bloqueio corresponde apenas pelo nome — o remetente pode alterar o nome para o contornar."; +"chats.blockSender.limitation" = "As mensagens de canal não incluem a identidade do remetente. Este bloqueio baseia-se apenas no nome, que o remetente pode alterar para o contornar."; /* Location: BlockSenderSheet.swift - Purpose: Section header when matching contacts found */ "chats.blockSender.matchingContacts" = "Os contactos seguintes partilham este nome. Selecione os que também devem ser bloqueados:"; @@ -1118,10 +1118,10 @@ // MARK: - Send DM Sheet /* Location: SendDMSheet.swift - Purpose: Sheet title with sender name */ -"chats.sendDM.title" = "Enviar DM a \"%@\""; +"chats.sendDM.title" = "Enviar mensagem direta a \"%@\""; /* Location: SendDMSheet.swift - Purpose: Name-based matching limitation warning */ -"chats.sendDM.limitation" = "As mensagens de canal não incluem a identidade do remetente. Esta correspondência é apenas pelo nome — o contacto pode ser outra pessoa com o mesmo nome."; +"chats.sendDM.limitation" = "As mensagens de canal não incluem a identidade do remetente. O contacto é identificado apenas pelo nome, por isso pode ser outra pessoa com o mesmo nome."; /* Location: SendDMSheet.swift - Purpose: Section header listing matching contacts */ "chats.sendDM.matchingContacts" = "Selecione um contacto para enviar mensagem:"; @@ -1146,7 +1146,7 @@ "chats.channelInfo.region" = "Região"; /* Location: ChannelInfoSheet.swift - Purpose: Region value when no scope set */ -"chats.channelInfo.region.allRegions" = "Sem âmbito"; +"chats.channelInfo.region.allRegions" = "Sem scope"; /* Location: ChannelInfoSheet.swift - Purpose: Picker row and summary when channel inherits the device default flood scope */ "chats.channelInfo.region.useDefaultFormat" = "%@ (predefinição)"; @@ -1188,7 +1188,7 @@ "chats.channelInfo.region.errLoadingRepeaters" = "Não foi possível carregar os repetidores próximos"; /* Location: ChannelInfoSheet.swift - Purpose: Some queries failed because radio contact list is full */ -"chats.channelInfo.region.errRadioContactsFull" = "A lista de contactos do rádio está cheia — alguns repetidores não puderam ser consultados"; +"chats.channelInfo.region.errRadioContactsFull" = "A lista de contactos do rádio está cheia. Não foi possível obter dados de alguns repetidores"; /* Location: RegionDiscoveryResultsView.swift - Purpose: Add selected regions button */ "chats.channelInfo.region.addSelected" = "Adicionar"; @@ -1231,10 +1231,10 @@ "chats.mention.picker.notSavedSubtitle" = "@%@ não corresponde a nenhum contacto neste rádio."; /* Location: ChatConversationView.swift - Mention picker title when the tapped name is the user's own node */ -"chats.mention.picker.selfTitle" = "É o nodo local"; +"chats.mention.picker.selfTitle" = "É o seu nó"; /* Location: ChatConversationView.swift - Mention picker description when the tapped name is the user's own node */ -"chats.mention.picker.selfSubtitle" = "@%@ é o nome deste nodo."; +"chats.mention.picker.selfSubtitle" = "@%@ é o nome deste nó."; /* Location: ChatConversationView.swift - Mention picker section header above the list of matching contacts */ "chats.mention.picker.matchingContacts" = "Contactos correspondentes"; @@ -1245,7 +1245,7 @@ "chats.message.sender.unverifiedNickname" = "Nome não verificado"; /* Location: UnifiedMessageBubble.swift - Explanation of why the sender name is unverified */ -"chats.message.sender.unverifiedNicknameExplanation" = "Os remetentes de mensagens de canal não podem ser verificados. Pode ser outra pessoa."; +"chats.message.sender.unverifiedNicknameExplanation" = "Os remetentes das mensagens do canal não podem ser verificados. Pode tratar-se de outra pessoa."; /* Location: UnifiedMessageBubble.swift - Accessibility label for unverified nickname indicator */ "chats.message.sender.unverifiedNicknameAccessibilityLabel" = "Correspondência de nome não verificado"; diff --git a/MC1/Resources/Localization/pt.lproj/Contacts.strings b/MC1/Resources/Localization/pt.lproj/Contacts.strings index a9e1a630c..af52b4a1f 100644 --- a/MC1/Resources/Localization/pt.lproj/Contacts.strings +++ b/MC1/Resources/Localization/pt.lproj/Contacts.strings @@ -18,7 +18,7 @@ "contacts.common.save" = "Guardar"; /* Location: Multiple files - Purpose: Generic Done button */ -"contacts.common.done" = "OK"; +"contacts.common.done" = "Concluído"; /* Location: Multiple files - Purpose: Generic Delete button */ "contacts.common.delete" = "Eliminar"; @@ -53,7 +53,7 @@ // MARK: - Route Types /* Location: ContactDetailView.swift, ContactRowView.swift - Purpose: Flood routing label */ -"contacts.route.flood" = "Flood"; +"contacts.route.flood" = "Difusão"; /* Location: ContactDetailView.swift, ContactRowView.swift - Purpose: Direct routing label */ "contacts.route.direct" = "Direto"; @@ -76,12 +76,12 @@ "contacts.segment.rooms" = "Salas"; /* Location: NodeSegmentPicker.swift - Purpose: VoiceOver label for the Nodes tab segment filter picker (iOS 18 fallback) */ -"contacts.segment.pickerLabel" = "Filtrar nodos"; +"contacts.segment.pickerLabel" = "Filtrar nós"; // MARK: - Sort Order /* Location: ContactsViewModel.swift - Purpose: Last heard sort option */ -"contacts.sort.lastHeard" = "Última receção"; +"contacts.sort.lastHeard" = "Última alteração"; /* Location: ContactsViewModel.swift - Purpose: Name sort option */ "contacts.sort.name" = "Nome"; @@ -95,13 +95,13 @@ // MARK: - Contacts List /* Location: ContactsListView.swift - Purpose: Navigation title */ -"contacts.list.title" = "Nodos"; +"contacts.list.title" = "Nós"; /* Location: ContactsListView.swift - Purpose: Search prompt */ -"contacts.list.searchPrompt" = "Pesquisar nodos"; +"contacts.list.searchPrompt" = "Pesquisar nós"; /* Location: ContactsListView.swift - Purpose: Search prompt with count */ -"contacts.list.searchPromptWithCount" = "Pesquisar nodos (%d)"; +"contacts.list.searchPromptWithCount" = "Pesquisar nós (%d)"; /* Location: ContactsListView.swift - Purpose: Sort menu label */ "contacts.list.sort" = "Ordenar"; @@ -122,10 +122,10 @@ "contacts.list.discover" = "Descobrir"; /* Location: ContactsListView.swift - Purpose: Menu item to sync nodes */ -"contacts.list.syncNodes" = "Sincronizar nodos"; +"contacts.list.syncNodes" = "Sincronizar nós"; /* Location: ContactsListView.swift - Purpose: Empty state for split view */ -"contacts.list.selectNode" = "Selecione um nodo"; +"contacts.list.selectNode" = "Selecione um nó"; /* Location: ContactsListView.swift - Purpose: Refresh alert title */ "contacts.list.cannotRefresh" = "Não é possível atualizar"; @@ -151,7 +151,7 @@ "contacts.list.empty.favorites.title" = "Ainda não há favoritos"; /* Location: ContactsListView.swift - Purpose: No favorites empty description */ -"contacts.list.empty.favorites.description" = "Toque e mantenha premido qualquer nodo para o adicionar aos favoritos."; +"contacts.list.empty.favorites.description" = "Mantenha premido qualquer nó para o adicionar aos favoritos."; /* Location: ContactsListView.swift - Purpose: No contacts empty title */ "contacts.list.empty.contacts.title" = "Nenhum contacto"; @@ -175,7 +175,7 @@ "contacts.list.empty.search.title" = "Nenhum resultado"; /* Location: ContactsListView.swift - Purpose: No search results description */ -"contacts.list.empty.search.description" = "Nenhum nodo corresponde a '%@'"; +"contacts.list.empty.search.description" = "Nenhum nó corresponde a '%@'"; // MARK: - Contacts List Row @@ -244,7 +244,7 @@ "contacts.detail.shareViaAdvert" = "Partilhar contacto via Advert"; /* Location: ContactDetailView.swift - Purpose: Share contact error when advert is missing or stale */ -"contacts.detail.shareContactUnavailable" = "Não foi possível partilhar o nodo. O Advert do nodo pode estar em falta ou ser demasiado antigo."; +"contacts.detail.shareContactUnavailable" = "Não foi possível partilhar o nó. O Advert do nó pode estar em falta ou ser demasiado antigo."; /* Location: ContactDetailView.swift - Purpose: Generalized ping button for non-repeater nodes */ "contacts.detail.ping" = "Ping zero-hop"; @@ -343,7 +343,7 @@ // MARK: - Contact Detail Network Path Section /* Location: ContactDetailView.swift - Purpose: Outbound path section header */ -"contacts.detail.outboundPath" = "Caminho de saída"; +"contacts.detail.outboundPath" = "Rota de saída"; /* Location: ContactDetailView.swift - Purpose: Route label */ "contacts.detail.route" = "Rota"; @@ -355,28 +355,28 @@ "contacts.detail.hopsAway" = "Distância em saltos"; /* Location: ContactDetailView.swift - Purpose: Path discovery in progress */ -"contacts.detail.discoveringPath" = "A descobrir o caminho..."; +"contacts.detail.discoveringPath" = "A descobrir a rota..."; /* Location: ContactDetailView.swift - Purpose: Discovery countdown */ "contacts.detail.secondsRemaining" = "Até %d segundos restantes"; /* Location: ContactDetailView.swift - Purpose: Discover path button */ -"contacts.detail.discoverPath" = "Descobrir caminho"; +"contacts.detail.discoverPath" = "Descobrir rota"; /* Location: ContactDetailView.swift - Purpose: Edit path button */ -"contacts.detail.editPath" = "Editar caminho"; +"contacts.detail.editPath" = "Editar rota"; /* Location: ContactDetailView.swift - Purpose: Reset path button */ -"contacts.detail.resetPath" = "Repor caminho"; +"contacts.detail.resetPath" = "Repor rota"; /* Location: ContactDetailView.swift - Purpose: Footer for flood routing */ -"contacts.detail.floodFooter" = "As mensagens são enviadas a todos os nodos. Use Descobrir caminho para encontrar uma rota ideal."; +"contacts.detail.floodFooter" = "As mensagens são enviadas a todos os nós. Use Descobrir rota para encontrar a melhor."; /* Location: ContactDetailView.swift - Purpose: Footer for path routing */ -"contacts.detail.pathFooter" = "As mensagens seguem o caminho mostrado. Repor o caminho para usar o encaminhamento Flood."; +"contacts.detail.pathFooter" = "As mensagens seguem a rota mostrada. Reponha-a para voltar à difusão."; /* Location: ContactDetailView.swift - Purpose: Accessibility label for flood route */ -"contacts.detail.routeFlood" = "Rota: Flood"; +"contacts.detail.routeFlood" = "Rota: Difusão"; /* Location: ContactDetailView.swift - Purpose: Accessibility label for direct route */ "contacts.detail.routeDirect" = "Rota: Direto"; @@ -387,7 +387,7 @@ // MARK: - Contact Detail Technical Section /* Location: ContactDetailView.swift - Purpose: Technical section header */ -"contacts.detail.technical" = "Técnico"; +"contacts.detail.technical" = "Detalhes técnicos"; /* Location: ContactDetailView.swift - Purpose: Public key label */ "contacts.detail.publicKey" = "Chave pública"; @@ -398,7 +398,7 @@ // MARK: - Contact Detail Danger Section /* Location: ContactDetailView.swift - Purpose: Danger zone section header */ -"contacts.detail.dangerZone" = "Zona de perigo"; +"contacts.detail.dangerZone" = "Zona perigosa"; /* Location: ContactDetailView.swift - Purpose: Clear messages button */ "contacts.detail.clearMessages" = "Limpar mensagens"; @@ -421,25 +421,25 @@ "contacts.detail.alert.block.title" = "Bloquear contacto"; /* Location: ContactDetailView.swift - Purpose: Block contact alert message */ -"contacts.detail.alert.block.message" = "Não serão recebidas mensagens de %@. As conversas desse contacto ficam ocultas na lista de Chats e as novas mensagens de canal são descartadas. Ao desbloquear, as novas mensagens passam a ser permitidas, mas as mensagens descartadas não podem ser recuperadas."; +"contacts.detail.alert.block.message" = "Deixará de receber mensagens de %@. A conversa fica oculta na lista e as mensagens deste contacto nos canais são ignoradas. Se desbloquear, volta a receber mensagens, mas as que foram ignoradas não são recuperadas."; /* Location: ContactDetailView.swift - Purpose: Delete contact alert title */ "contacts.detail.alert.delete.title" = "Eliminar %@"; /* Location: ContactDetailView.swift - Purpose: Delete contact alert message */ -"contacts.detail.alert.delete.message" = "Isto irá remover %@ e eliminar todos os dados associados. Esta ação não pode ser anulada."; +"contacts.detail.alert.delete.message" = "%@ e todos os dados associados são eliminados. Esta ação não pode ser anulada."; /* Location: ContactDetailView.swift - Purpose: Clear messages alert title */ "contacts.detail.alert.clearMessages.title" = "Limpar mensagens?"; /* Location: ContactDetailView.swift - Purpose: Clear messages alert message */ -"contacts.detail.alert.clearMessages.message" = "Todas as mensagens com %@ serão eliminadas de forma permanente."; +"contacts.detail.alert.clearMessages.message" = "Todas as mensagens com %@ são eliminadas de forma permanente."; /* Location: ContactDetailView.swift - Purpose: Path error alert title */ -"contacts.detail.alert.pathError" = "Erro de caminho"; +"contacts.detail.alert.pathError" = "Erro de rota"; /* Location: ContactDetailView.swift - Purpose: Path discovery alert title */ -"contacts.detail.alert.pathDiscovery" = "Descoberta de caminho"; +"contacts.detail.alert.pathDiscovery" = "Descoberta de rota"; // MARK: - Blocked Contacts @@ -459,13 +459,13 @@ /* Location: AddContactSheet.swift - Purpose: Navigation title */ "contacts.add.title" = "Adicionar contacto"; -"contacts.add.nodeTitle" = "Adicionar nodo"; +"contacts.add.nodeTitle" = "Adicionar nó"; /* Location: AddContactSheet.swift - Purpose: Add button */ "contacts.add.add" = "Adicionar"; /* Location: AddContactSheet.swift - Purpose: Scan QR button label */ -"contacts.add.scanQR" = "Digitalizar código QR"; +"contacts.add.scanQR" = "Ler código QR"; /* Location: AddContactSheet.swift - Purpose: Type section header */ "contacts.add.type" = "Tipo"; @@ -501,10 +501,10 @@ "contacts.add.error.invalidSize" = "A chave pública tem de ter %d bytes (%d caracteres hexadecimais)"; /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error with max count */ -"contacts.add.error.nodeListFull" = "A lista de nodos está cheia (máximo de %d nodos)"; +"contacts.add.error.nodeListFull" = "A lista de nós está cheia (máximo de %d nós)"; /* Location: AddContactSheet.swift, DiscoveryView.swift - Purpose: Node list full error without max count */ -"contacts.add.error.nodeListFullSimple" = "A lista de nodos está cheia"; +"contacts.add.error.nodeListFullSimple" = "A lista de nós está cheia"; /* Location: AddContactSheet.swift - Purpose: Paste URL button label */ "contacts.add.pasteURL" = "Colar URL do contacto"; @@ -541,13 +541,13 @@ // MARK: - Scan Contact QR /* Location: ScanContactQRView.swift - Purpose: Navigation title */ -"contacts.scan.title" = "Digitalizar código QR"; +"contacts.scan.title" = "Ler código QR"; /* Location: ScanContactQRView.swift - Purpose: Scanner not available title */ "contacts.scan.unavailable.title" = "Scanner indisponível"; /* Location: ScanContactQRView.swift - Purpose: Scanner not available description */ -"contacts.scan.unavailable.description" = "A digitalização de códigos QR não é suportada neste dispositivo"; +"contacts.scan.unavailable.description" = "A leitura de códigos QR não é suportada neste dispositivo"; /* Location: ScanContactQRView.swift - Purpose: Importing progress */ "contacts.scan.importing" = "A importar o contacto..."; @@ -559,7 +559,7 @@ "contacts.scan.permission.title" = "É necessário o acesso à câmara"; /* Location: ScanContactQRView.swift - Purpose: Camera permission description */ -"contacts.scan.permission.description" = "Ative o acesso à câmara em Definições para digitalizar códigos QR."; +"contacts.scan.permission.description" = "Ative o acesso à câmara em Definições para ler códigos QR."; /* Location: ScanContactQRView.swift - Purpose: Invalid QR format error */ "contacts.scan.error.invalidFormat" = "Formato de código QR inválido"; @@ -575,10 +575,10 @@ "contacts.discovery.title" = "Descobrir"; /* Location: DiscoveryView.swift - Purpose: Empty state title */ -"contacts.discovery.empty.title" = "Nenhum nodo descoberto"; +"contacts.discovery.empty.title" = "Nenhum nó descoberto"; /* Location: DiscoveryView.swift - Purpose: Empty state description */ -"contacts.discovery.empty.description" = "Os nodos aparecem aqui à medida que os respetivos Adverts são descobertos."; +"contacts.discovery.empty.description" = "Os nós aparecem aqui à medida que os respetivos Adverts são descobertos."; /* Location: DiscoveryView.swift - Purpose: Add button */ "contacts.discovery.add" = "Adicionar"; @@ -603,7 +603,7 @@ "contacts.discovery.segment.rooms" = "Salas"; /* Location: DiscoverSegmentPicker.swift - Purpose: VoiceOver label for the Discovery segment filter picker (iOS 18 fallback) */ -"contacts.discovery.segment.pickerLabel" = "Filtrar nodos descobertos"; +"contacts.discovery.segment.pickerLabel" = "Filtrar nós descobertos"; /* Location: DiscoveryView.swift - Purpose: Search prompt */ "contacts.discovery.searchPrompt" = "Pesquisar descobertos"; @@ -615,10 +615,10 @@ "contacts.discovery.clear" = "Limpar tudo"; /* Location: DiscoveryView.swift - Purpose: Clear confirmation title */ -"contacts.discovery.clear.title" = "Limpar todos os nodos descobertos?"; +"contacts.discovery.clear.title" = "Limpar todos os nós descobertos?"; /* Location: DiscoveryView.swift - Purpose: Clear confirmation message */ -"contacts.discovery.clear.message" = "Os nodos descobertos serão removidos desta lista, mas podem ser descobertos novamente na rede mesh."; +"contacts.discovery.clear.message" = "Os nós descobertos serão removidos desta lista, mas podem ser descobertos novamente na rede mesh."; /* Location: DiscoveryView.swift - Purpose: Clear confirmation button */ "contacts.discovery.clear.confirm" = "Limpar"; @@ -630,10 +630,10 @@ "contacts.discovery.sortMenu" = "Opções de ordenação"; /* Location: DiscoveryView.swift - Purpose: Sort menu accessibility hint */ -"contacts.discovery.sortMenuHint" = "Escolha como ordenar os nodos descobertos"; +"contacts.discovery.sortMenuHint" = "Escolha como ordenar os nós descobertos"; /* Location: DiscoveryView.swift - Purpose: VoiceOver announcement after clearing */ -"contacts.discovery.clearedAllNodes" = "Todos os nodos descobertos foram limpos"; +"contacts.discovery.clearedAllNodes" = "Todos os nós descobertos foram limpos"; /* Location: DiscoveryView.swift - Purpose: Swipe action to remove discovered node */ "contacts.discovery.remove" = "Remover"; @@ -642,18 +642,18 @@ "contacts.discovery.empty.search.title" = "Nenhum resultado"; /* Location: DiscoveryView.swift - Purpose: Search empty state description */ -"contacts.discovery.empty.search.description" = "Nenhum nodo descoberto corresponde a '%@'"; +"contacts.discovery.empty.search.description" = "Nenhum nó descoberto corresponde a '%@'"; // MARK: - Path Editing Sheet /* Location: PathEditingSheet.swift - Purpose: Navigation title */ -"contacts.pathEdit.title" = "Editar caminho"; +"contacts.pathEdit.title" = "Editar rota"; /* Location: PathEditingSheet.swift - Purpose: Description with contact name */ "contacts.pathEdit.description" = "Personalize a rota que as mensagens seguem para chegar a %@."; /* Location: PathEditingSheet.swift - Purpose: Current path section header */ -"contacts.pathEdit.currentPath" = "Caminho atual"; +"contacts.pathEdit.currentPath" = "Rota atual"; /* Location: AddHopPickerView.swift - Purpose: No repeaters empty title */ "contacts.pathEdit.noRepeaters.title" = "Nenhum repetidor disponível"; @@ -671,7 +671,7 @@ "contacts.pathEdit.noRecent.title" = "Nenhum repetidor recente"; /* Location: AddHopPickerView.swift - Purpose: No recent repeaters empty description */ -"contacts.pathEdit.noRecent.description" = "Os repetidores adicionados a um caminho aparecem aqui."; +"contacts.pathEdit.noRecent.description" = "Os repetidores adicionados a uma rota aparecem aqui."; /* Location: PathEditingSheet.swift - Purpose: Hop accessibility with name */ "contacts.pathEdit.hopWithName" = "Salto %d de %d: %@"; @@ -686,13 +686,13 @@ "contacts.pathEdit.paste" = "Colar da área de transferência"; /* Location: PathEditingSheet.swift - Purpose: Use-direct-routing empty-state button */ -"contacts.pathEdit.useDirectRouting" = "Usar encaminhamento direto"; +"contacts.pathEdit.useDirectRouting" = "Usar rota direta"; /* Location: PathEditingSheet.swift - Purpose: Use-flood-routing empty-state button */ -"contacts.pathEdit.useFloodRouting" = "Usar encaminhamento Flood"; +"contacts.pathEdit.useFloodRouting" = "Usar difusão"; /* Location: PathEditingSheet.swift - Purpose: Direct-routing confirmation alert title */ -"contacts.pathEdit.directRouting.confirm.title" = "Guardar como encaminhamento direto?"; +"contacts.pathEdit.directRouting.confirm.title" = "Guardar como rota direta?"; /* Location: PathEditingSheet.swift - Purpose: Direct-routing confirmation alert message, %@ is contact name */ "contacts.pathEdit.directRouting.confirm.message" = "As mensagens para %@ serão enviadas diretamente, sem passar por nenhum repetidor. Use apenas se %@ for um vizinho direto (0-hop)."; @@ -701,13 +701,13 @@ "contacts.pathEdit.directRouting.confirm.confirm" = "Guardar como direto"; /* Location: PathEditingSheet.swift - Purpose: Flood-routing confirmation alert title */ -"contacts.pathEdit.floodRouting.confirm.title" = "Usar encaminhamento Flood?"; +"contacts.pathEdit.floodRouting.confirm.title" = "Usar rota por difusão?"; /* Location: PathEditingSheet.swift - Purpose: Flood-routing confirmation alert message, %@ is contact name */ -"contacts.pathEdit.floodRouting.confirm.message" = "As mensagens para %@ serão enviadas a todos os nodos próximos até ser encontrado um caminho. Use quando não for conhecida nenhuma rota de repetidor."; +"contacts.pathEdit.floodRouting.confirm.message" = "As mensagens para %@ serão enviadas a todos os nós próximos até ser encontrada uma rota. Use quando não for conhecida nenhuma rota de repetidor."; /* Location: PathEditingSheet.swift - Purpose: Flood-routing confirmation confirm label */ -"contacts.pathEdit.floodRouting.confirm.confirm" = "Usar encaminhamento Flood"; +"contacts.pathEdit.floodRouting.confirm.confirm" = "Usar rota por difusão"; /* Location: AddHopPickerView.swift - Purpose: Position banner when appending, %d is target hop number */ "contacts.pathEdit.positionAppend" = "A adicionar como salto %d"; @@ -722,7 +722,7 @@ "contacts.pathEdit.empty.title" = "Ainda não há saltos"; /* Location: PathEditingSheet.swift - Purpose: Empty-state description, %@ is contact name */ -"contacts.pathEdit.empty.description" = "Adicione repetidores para controlar como as mensagens chegam a %@ ou use o encaminhamento Flood se o caminho não for conhecido."; +"contacts.pathEdit.empty.description" = "Adicione repetidores para controlar como as mensagens chegam a %@, ou use a difusão se a rota não for conhecida."; /* Location: AddHopSegmentPicker.swift - Purpose: All filter */ "contacts.pathEdit.filter.all" = "Todos"; @@ -758,7 +758,7 @@ "contacts.pathEdit.search.noMatches.descriptionWithRoomsHint" = "Tente um nome ou um prefixo hexadecimal como 'a3'. As salas só aparecem no filtro Todos."; /* Location: AddHopPickerView.swift - Purpose: Picker row accessibility label, %1$@ is name, %2$d is target hop number */ -"contacts.pathEdit.addToPathAsHop" = "Adicionar %1$@ ao caminho como salto %2$d"; +"contacts.pathEdit.addToPathAsHop" = "Adicionar %1$@ à rota como salto %2$d"; /* Location: PathEditingSheet.swift - Purpose: VoiceOver hint for a hop row describing swipe + drag actions */ "contacts.pathEdit.hopHint" = "Deslize para eliminar. Arraste para reordenar."; @@ -770,13 +770,13 @@ "contacts.pathEdit.maxHops.reached" = "Máximo de saltos atingido"; /* Location: AddHopPickerView.swift - Purpose: Max-hops reached description, %d is the cap */ -"contacts.pathEdit.maxHops.description" = "Este caminho atingiu o máximo de %d saltos no modo de hash atual. Remova um salto para adicionar outro."; +"contacts.pathEdit.maxHops.description" = "Esta rota atingiu o máximo de %d saltos no modo de hash atual. Remova um salto para adicionar outro."; /* Location: PathEditingSheet.swift - Purpose: Add Hop CTA footer explaining the hop cap, %d is the cap */ "contacts.pathEdit.maxHops.footer" = "Máximo de %d saltos atingido. Remova um salto para adicionar outro."; /* Location: AddHopPickerView.swift - Purpose: Bulk-add action button, %@ is the comma-joined codes */ -"contacts.pathEdit.bulkAdd.action" = "Adicionar nodos: %@"; +"contacts.pathEdit.bulkAdd.action" = "Adicionar nós: %@"; /* Location: AddHopPickerView.swift - Purpose: Bulk-add row, code will be added, %@ is the code */ "contacts.pathEdit.bulkAdd.willAdd" = "%@ será adicionado"; @@ -785,7 +785,7 @@ "contacts.pathEdit.bulkAdd.pathFull" = "%@ excede o limite de saltos"; /* Location: AddHopPickerView.swift - Purpose: Bulk-add action button when nothing is addable */ -"contacts.pathEdit.bulkAdd.empty" = "Não há nodos novos para adicionar"; +"contacts.pathEdit.bulkAdd.empty" = "Não há nós novos para adicionar"; // MARK: - Path Discovery Results @@ -799,7 +799,7 @@ "contacts.pathDiscovery.hops.plural" = "%d saltos"; /* Location: PathManagementViewModel.swift - Purpose: No response message */ -"contacts.pathDiscovery.noResponse" = "O nodo remoto não respondeu. Os nodos precisam de ter os pedidos de telemetria ativados para responder à descoberta de caminho."; +"contacts.pathDiscovery.noResponse" = "O nó remoto não respondeu. Só é possível traçar a rota se o nó tiver os pedidos de telemetria ativados."; /* Location: PathManagementViewModel.swift - Purpose: Failed prefix */ "contacts.pathDiscovery.failed" = "Falha: %@"; @@ -807,25 +807,25 @@ // MARK: - Saved Paths Sheet /* Location: SavedPathsSheet.swift - Purpose: Navigation title */ -"contacts.savedPaths.title" = "Caminhos guardados"; +"contacts.savedPaths.title" = "Rotas guardadas"; /* Location: SavedPathsSheet.swift - Purpose: Delete dialog title */ -"contacts.savedPaths.deleteTitle" = "Eliminar caminho"; +"contacts.savedPaths.deleteTitle" = "Eliminar rota"; /* Location: SavedPathsSheet.swift - Purpose: Delete dialog message */ -"contacts.savedPaths.deleteMessage" = "Eliminar \"%@\"? Isto irá remover o caminho e todo o histórico de execuções."; +"contacts.savedPaths.deleteMessage" = "Eliminar \"%@\"? Isto remove a rota e todo o histórico de execuções."; /* Location: SavedPathsSheet.swift - Purpose: Rename alert title */ -"contacts.savedPaths.renameTitle" = "Renomear caminho"; +"contacts.savedPaths.renameTitle" = "Renomear rota"; /* Location: SavedPathsSheet.swift - Purpose: Rename context menu */ "contacts.savedPaths.rename" = "Renomear"; /* Location: SavedPathsSheet.swift - Purpose: Empty state title */ -"contacts.savedPaths.empty.title" = "Nenhum caminho guardado"; +"contacts.savedPaths.empty.title" = "Nenhuma rota guardada"; /* Location: SavedPathsSheet.swift - Purpose: Empty state description */ -"contacts.savedPaths.empty.description" = "Guarde caminhos depois de executar traçados para os voltar a executar mais tarde."; +"contacts.savedPaths.empty.description" = "Guarde as rotas depois de as traçar, para as voltar a executar mais tarde."; /* Location: SavedPathsSheet.swift - Purpose: Run count singular */ "contacts.savedPaths.runs.singular" = "1 execução"; @@ -846,7 +846,7 @@ "contacts.savedPaths.health.poor" = "fraco"; /* Location: SavedPathsSheet.swift - Purpose: Health accessibility label */ -"contacts.savedPaths.healthLabel" = "Estado do caminho: %@, taxa de sucesso de %d%%"; +"contacts.savedPaths.healthLabel" = "Estado da rota: %@, taxa de sucesso de %d%%"; /* Location: SavedPathsSheet.swift - Purpose: Response times accessibility */ "contacts.savedPaths.responseTimes" = "Tempos de resposta: média de %dms, %@"; @@ -866,7 +866,7 @@ // MARK: - Saved Path Detail /* Location: SavedPathDetailView.swift - Purpose: Path section header */ -"contacts.pathDetail.path" = "Caminho"; +"contacts.pathDetail.path" = "Rota"; /* Location: SavedPathDetailView.swift - Purpose: Performance section header */ "contacts.pathDetail.performance" = "Desempenho"; @@ -913,7 +913,7 @@ // MARK: - Trace Path View /* Location: TracePathView.swift - Purpose: Navigation title */ -"contacts.trace.title" = "Traçar caminho"; +"contacts.trace.title" = "Traçar rota"; /* Location: TracePathView.swift - Purpose: View mode picker label */ "contacts.trace.viewMode" = "Modo de visualização"; @@ -928,45 +928,45 @@ "contacts.trace.saved" = "Guardados"; /* Location: TracePathView.swift - Purpose: Clear path dialog title */ -"contacts.trace.clearPath" = "Limpar caminho"; +"contacts.trace.clearPath" = "Limpar rota"; /* Location: TracePathView.swift - Purpose: Clear path dialog message */ -"contacts.trace.clearPathMessage" = "Remover todos os repetidores do caminho?"; +"contacts.trace.clearPathMessage" = "Remover todos os repetidores da rota?"; /* Location: TracePathView.swift - Purpose: Trace failed alert title */ -"contacts.trace.failed" = "Falha no traçado"; +"contacts.trace.failed" = "Falha ao traçar"; /* Location: TracePathView.swift - Purpose: Jump button label */ "contacts.trace.runBelow" = "Ir para Executar"; /* Location: TracePathView.swift - Purpose: Jump button accessibility label */ -"contacts.trace.jumpLabel" = "Ir para o botão Executar traçado"; +"contacts.trace.jumpLabel" = "Ir para o botão Traçar rota"; /* Location: TracePathView.swift - Purpose: Jump button accessibility hint */ -"contacts.trace.jumpHint" = "Toque duas vezes para ir até ao fim do caminho"; +"contacts.trace.jumpHint" = "Toque duas vezes para ir até ao fim da rota"; // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Empty path instruction */ -"contacts.trace.list.emptyPath" = "Adicione um salto para começar a montar o caminho."; +"contacts.trace.list.emptyPath" = "Adicione um salto para começar a montar a rota."; /* Location: TracePathListView.swift - Purpose: Round trip path section header */ -"contacts.trace.list.roundTripPath" = "Caminho de ida e volta"; +"contacts.trace.list.roundTripPath" = "Rota de ida e volta"; /* Location: TracePathListView.swift - Purpose: Auto return toggle label */ -"contacts.trace.list.autoReturn" = "Caminho de regresso automático"; +"contacts.trace.list.autoReturn" = "Rota de regresso automático"; /* Location: TracePathListView.swift - Purpose: Auto return toggle description */ -"contacts.trace.list.autoReturnDescription" = "Espelhar o caminho de saída no regresso"; +"contacts.trace.list.autoReturnDescription" = "Espelhar a rota de saída no regresso"; /* Location: TracePathListView.swift - Purpose: Batch trace toggle label */ -"contacts.trace.list.batchTrace" = "Traçado em lote"; +"contacts.trace.list.batchTrace" = "Traçar em lote"; /* Location: TracePathListView.swift - Purpose: Batch trace toggle description */ -"contacts.trace.list.batchTraceDescription" = "Executar vários traçados e calcular a média dos resultados"; +"contacts.trace.list.batchTraceDescription" = "Traçar a rota várias vezes e calcular a média dos resultados"; /* Location: TracePathListView.swift - Purpose: Traces count label */ -"contacts.trace.list.traces" = "Traçados:"; +"contacts.trace.list.traces" = "Tentativas:"; /* Location: PathActionsSectionView.swift - Purpose: Trace hash size picker label */ "contacts.trace.list.hashSize" = "Tamanho do hash"; @@ -981,37 +981,37 @@ "contacts.trace.list.hashSizeFourBytes" = "4 bytes"; /* Location: PathActionsSectionView.swift - Purpose: Caveat shown for multi-byte trace hash sizes */ -"contacts.trace.list.hashSizeFooter" = "Os repetidores com firmware anterior a 1.11.0 não reencaminham traçados com um tamanho de hash superior a 1 byte."; +"contacts.trace.list.hashSizeFooter" = "Os repetidores com firmware anterior a 1.11.0 não reencaminham pedidos com um tamanho do hash da rota superior a 1 byte."; /* Location: TracePathListView.swift - Purpose: Copy path button */ -"contacts.trace.list.copyPath" = "Copiar caminho"; +"contacts.trace.list.copyPath" = "Copiar rota"; /* Location: TracePathListView.swift - Purpose: Range warning footer */ "contacts.trace.list.rangeWarning" = "É necessário estar ao alcance do último repetidor para receber uma resposta."; /* Location: TracePathListView.swift - Purpose: Running trace progress */ -"contacts.trace.list.runningTrace" = "A executar o traçado"; +"contacts.trace.list.runningTrace" = "A traçar a rota"; /* Location: TracePathListView.swift - Purpose: Running trace with batch count */ -"contacts.trace.list.runningBatch" = "A executar o traçado %d de %d"; +"contacts.trace.list.runningBatch" = "A traçar %d de %d"; /* Location: TracePathListView.swift - Purpose: Run trace button */ -"contacts.trace.list.runTrace" = "Executar traçado"; +"contacts.trace.list.runTrace" = "Traçar"; /* Location: TracePathListView.swift - Purpose: Run trace accessibility label */ -"contacts.trace.list.runTraceLabel" = "Executar traçado"; +"contacts.trace.list.runTraceLabel" = "Traçar rota"; /* Location: TracePathListView.swift - Purpose: Batch run accessibility hint */ -"contacts.trace.list.batchHint" = "Toque duas vezes para executar %d traçados"; +"contacts.trace.list.batchHint" = "Toque duas vezes para traçar %d vezes"; /* Location: TracePathListView.swift - Purpose: Single run accessibility hint */ -"contacts.trace.list.singleHint" = "Toque duas vezes para traçar o caminho"; +"contacts.trace.list.singleHint" = "Toque duas vezes para traçar a rota"; /* Location: TracePathListView.swift - Purpose: Running accessibility label */ -"contacts.trace.list.runningLabel" = "A executar o traçado, aguarde"; +"contacts.trace.list.runningLabel" = "A traçar a rota, aguarde"; /* Location: TracePathListView.swift - Purpose: Running batch accessibility label */ -"contacts.trace.list.runningBatchLabel" = "A executar o traçado %d de %d"; +"contacts.trace.list.runningBatchLabel" = "A traçar %d de %d"; /* Location: TracePathListView.swift - Purpose: Hop row accessibility label */ "contacts.trace.list.hopLabel" = "Salto %d: %@"; @@ -1022,7 +1022,7 @@ // MARK: - Trace Path List Accessibility /* Location: TracePathListView.swift - Accessibility hint when trace is running */ -"contacts.trace.list.runningHint" = "O traçado está em curso"; +"contacts.trace.list.runningHint" = "A traçar a rota"; // MARK: - Trace Path Cluster View @@ -1046,39 +1046,39 @@ /* Location: TracePathMapView.swift - Purpose: Hide labels accessibility */ /* Location: TracePathMapView.swift - Purpose: Show labels accessibility */ /* Location: TracePathMapView.swift - Purpose: Center on path accessibility */ -"contacts.trace.map.centerOnPath" = "Centrar no caminho"; +"contacts.trace.map.centerOnPath" = "Centrar na rota"; /* Location: TracePathMapView.swift - Purpose: Save path alert title */ -"contacts.trace.map.saveTitle" = "Guardar caminho"; +"contacts.trace.map.saveTitle" = "Guardar rota"; /* Location: TracePathMapView.swift - Purpose: Save path alert message */ -"contacts.trace.map.saveMessage" = "Introduza um nome para este caminho"; +"contacts.trace.map.saveMessage" = "Introduza um nome para esta rota"; /* Location: TracePathMapView.swift - Purpose: Path name placeholder */ -"contacts.trace.map.pathName" = "Nome do caminho"; +"contacts.trace.map.pathName" = "Nome da rota"; /* Location: TracePathMapView.swift - Purpose: Path saved alert title */ -"contacts.trace.map.savedTitle" = "Caminho guardado"; +"contacts.trace.map.savedTitle" = "Rota guardada"; /* Location: TracePathMapView.swift - Purpose: Path saved alert message */ -"contacts.trace.map.savedMessage" = "O caminho foi guardado."; +"contacts.trace.map.savedMessage" = "A rota foi guardada."; /* Location: TracePathMapView.swift - Purpose: Save failed alert title */ "contacts.trace.map.saveFailedTitle" = "Falha ao guardar"; /* Location: TracePathMapView.swift - Purpose: Save failed alert message */ -"contacts.trace.map.saveFailedMessage" = "Não foi possível guardar o caminho. Tente novamente."; +"contacts.trace.map.saveFailedMessage" = "Não foi possível guardar a rota. Tente novamente."; /* Location: TracePathMapView.swift - Purpose: View results button */ "contacts.trace.map.viewResults" = "Resultados"; /* Location: TracePathMapViewModel.swift - Purpose: Default path name fallback */ -"contacts.trace.map.defaultPathName" = "Caminho"; +"contacts.trace.map.defaultPathName" = "Rota"; // MARK: - Trace Results Sheet /* Location: TraceResultsSheet.swift - Purpose: Navigation title */ -"contacts.results.title" = "Resultados do traçado"; +"contacts.results.title" = "Resultados"; /* Location: TraceResultsSheet.swift - Purpose: Dismiss button */ "contacts.results.dismiss" = "Fechar"; @@ -1087,13 +1087,13 @@ "contacts.results.batchSuccess" = "%d de %d bem-sucedidos (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress */ -"contacts.results.batchProgress" = "A executar o traçado %d de %d..."; +"contacts.results.batchProgress" = "A traçar %d de %d..."; /* Location: TraceResultsSheet.swift - Purpose: Batch complete accessibility */ -"contacts.results.batchCompleteLabel" = "Lote concluído: %d de %d traçados bem-sucedidos (%d%%)"; +"contacts.results.batchCompleteLabel" = "Lote concluído: %d de %d tentativas bem-sucedidas (%d%%)"; /* Location: TraceResultsSheet.swift - Purpose: Batch progress accessibility */ -"contacts.results.batchProgressLabel" = "Progresso do lote: traçado %d de %d"; +"contacts.results.batchProgressLabel" = "Progresso do lote: %d de %d"; /* Location: TraceResultsSheet.swift - Purpose: Average round trip label */ "contacts.results.avgRoundTrip" = "Ida e volta média"; @@ -1139,10 +1139,10 @@ "contacts.results.partialDistanceHeader" = "Distância parcial"; /* Location: TraceResultsSheet.swift - Purpose: Full path tip */ -"contacts.results.fullPathTip" = "Ative os serviços de localização ou defina uma localização para o dispositivo para ver a distância completa do caminho."; +"contacts.results.fullPathTip" = "Ative os serviços de localização ou defina manualmente a localização do dispositivo, para ver a distância completa da rota."; /* Location: TraceResultsSheet.swift - Purpose: Full path section header */ -"contacts.results.fullPathHeader" = "Para incluir o caminho completo"; +"contacts.results.fullPathHeader" = "Para incluir a rota completa"; /* Location: TraceResultsSheet.swift - Purpose: Partial distance accessibility label */ "contacts.results.partialDistanceLabel" = "Distância parcial"; @@ -1151,7 +1151,7 @@ "contacts.results.partialDistanceHint" = "Toque duas vezes para saber porque é que a localização do dispositivo está excluída"; /* Location: TraceResultsSheet.swift - Purpose: Distance needs repeaters message */ -"contacts.results.needsRepeaters" = "O cálculo da distância exige pelo menos 2 repetidores no caminho."; +"contacts.results.needsRepeaters" = "O cálculo da distância exige pelo menos 2 repetidores na rota."; /* Location: TraceResultsSheet.swift - Purpose: Distance error message */ "contacts.results.distanceError" = "Não é possível calcular a distância. Todos os repetidores têm coordenadas, mas ocorreu um erro."; @@ -1163,7 +1163,7 @@ "contacts.results.repeatersWithoutLocations" = "Repetidores sem localização"; /* Location: TraceResultsSheet.swift - Purpose: Save path button */ -"contacts.results.savePath" = "Guardar caminho"; +"contacts.results.savePath" = "Guardar rota"; // MARK: - Trace Result Hop Row @@ -1171,13 +1171,13 @@ "contacts.results.hop.myDevice" = "O meu dispositivo"; /* Location: TraceResultsSheet.swift - Purpose: Started trace label */ -"contacts.results.hop.started" = "Traçado iniciado"; +"contacts.results.hop.started" = "Início"; /* Location: TraceResultsSheet.swift - Purpose: Received response label */ "contacts.results.hop.received" = "Resposta recebida"; /* Location: TraceResultsSheet.swift - Purpose: Repeated label */ -"contacts.results.hop.repeated" = "Repetido"; +"contacts.results.hop.repeated" = "Retransmitido"; /* Location: TraceResultsSheet.swift - Purpose: Average SNR display */ "contacts.results.hop.avgSNR" = "SNR médio: %@ dB (%@ – %@)"; @@ -1194,10 +1194,10 @@ "contacts.trace.error.noResponse" = "Nenhuma resposta recebida"; /* Location: TracePathViewModel.swift - Purpose: All traces failed error */ -"contacts.trace.error.allFailed" = "Todos os %d traçados falharam"; +"contacts.trace.error.allFailed" = "Falharam as %d tentativas"; /* Location: TracePathViewModel.swift - Purpose: Send failed error */ -"contacts.trace.error.sendFailed" = "Falha ao enviar o pacote de traçado"; +"contacts.trace.error.sendFailed" = "Falha ao enviar o pacote"; // MARK: - Stats Badge Accessibility @@ -1205,24 +1205,24 @@ // MARK: - Contact ViewModel Errors /* Location: ContactsViewModel.swift - Purpose: Delete requires connection error */ -"contacts.viewModel.connectToDelete" = "Ligue o dispositivo para eliminar nodos"; +"contacts.viewModel.connectToDelete" = "Ligue o dispositivo para eliminar nós"; /* Location: ContactsViewModel.swift - Purpose: Delete radio command timed out error */ -"contacts.viewModel.removeTimedOut" = "A eliminação do nodo expirou. Tente novamente."; +"contacts.viewModel.removeTimedOut" = "A eliminação do nó expirou. Tente novamente."; // MARK: - Path Management ViewModel Errors /* Location: PathManagementViewModel.swift - Purpose: Save path error prefix */ -"contacts.pathManagement.error.saveFailed" = "Falha ao guardar o caminho: %@"; +"contacts.pathManagement.error.saveFailed" = "Falha ao guardar a rota: %@"; /* Location: PathManagementViewModel.swift - Purpose: Reset path error prefix */ -"contacts.pathManagement.error.resetFailed" = "Falha ao repor o caminho: %@"; +"contacts.pathManagement.error.resetFailed" = "Falha ao repor a rota: %@"; /* Location: PathManagementViewModel.swift - Purpose: Set path error prefix */ -"contacts.pathManagement.error.setFailed" = "Falha ao definir o caminho: %@"; +"contacts.pathManagement.error.setFailed" = "Falha ao definir a rota: %@"; /* Location: PathManagementViewModel.swift - Purpose: Shown when a stored path's hop can't be resized to the device's current hash size */ -"contacts.pathManagement.error.hopResizeRequired" = "O tamanho do hash do caminho mudou — remova e volte a adicionar os saltos para guardar."; +"contacts.pathManagement.error.hopResizeRequired" = "O tamanho do hash da rota mudou. Remova e volte a adicionar os saltos para guardar."; /* Location: PathManagementViewModel.swift - Purpose: Shown when the existing path has more hops than the current hash mode supports */ "contacts.pathManagement.error.tooManyHops" = "Demasiados saltos. O modo de hash atual suporta no máximo %d."; @@ -1236,12 +1236,12 @@ "contacts.codeInput.error.notFound" = "%@ não encontrado"; /* Location: TracePathViewModel.swift - Purpose: Already in path error */ -"contacts.codeInput.error.alreadyInPath" = "%@ já está no caminho"; +"contacts.codeInput.error.alreadyInPath" = "%@ já está na rota"; // MARK: - Path Name Generation /* Location: TracePathViewModel.swift - Purpose: Default path name prefix for hash-only paths */ -"contacts.pathName.prefix" = "Caminho %@"; +"contacts.pathName.prefix" = "Rota %@"; /* Location: TracePathViewModel.swift - Purpose: Path name with two endpoints */ "contacts.pathName.twoEndpoints" = "%@ → %@"; diff --git a/MC1/Resources/Localization/pt.lproj/Localizable.strings b/MC1/Resources/Localization/pt.lproj/Localizable.strings index 723bb6dfd..6b581f2a6 100644 --- a/MC1/Resources/Localization/pt.lproj/Localizable.strings +++ b/MC1/Resources/Localization/pt.lproj/Localizable.strings @@ -15,7 +15,7 @@ "common.cancel" = "Cancelar"; /* Standard done button for completing an action */ -"common.done" = "OK"; +"common.done" = "Concluído"; /* Standard save button for persisting changes */ "common.save" = "Guardar"; @@ -37,13 +37,13 @@ "common.error.networkError" = "Erro de rede: %@"; /* Invalid response from API */ -"common.error.invalidResponse" = "Resposta inválida da API de elevação"; +"common.error.invalidResponse" = "Resposta inválida da API de altitude"; /* API error with message - %@ is the error message */ "common.error.apiError" = "Erro de API: %@"; /* No data returned from API */ -"common.error.noElevationData" = "Não foram devolvidos dados de elevação"; +"common.error.noElevationData" = "Não foram devolvidos dados de altitude"; /* Rate limited by elevation API */ "common.error.rateLimited" = "Demasiados pedidos. Tente novamente dentro de instantes."; @@ -66,13 +66,13 @@ // MARK: - Tab Bar /* Tab bar title for the messaging/conversations screen */ -"tabs.chats" = "Chats"; +"tabs.chats" = "Conversas"; /* VoiceOver value announcing the unread message count on the Chats sidebar icon. %d is the count. */ "tabs.chatsUnreadAccessibilityValue" = "%d por ler"; /* Tab bar title for the nodes/contacts list screen */ -"tabs.nodes" = "Nodos"; +"tabs.nodes" = "Nós"; /* Tab bar title for the map screen showing node locations */ "tabs.map" = "Mapa"; @@ -98,7 +98,7 @@ "alert.connectionFailed.defaultMessage" = "Não foi possível ligar ao dispositivo."; /* Message suggesting another app may be connected to the device */ -"alert.couldNotConnect.otherAppMessage" = "Certifique-se de que nenhum outro app está ligado ao dispositivo e tente novamente."; +"alert.couldNotConnect.otherAppMessage" = "Certifique-se de que nenhuma app está ligada ao dispositivo e tente novamente."; /* Button to remove failed pairing and retry connection */ "alert.connectionFailed.removeAndRetry" = "Remover e tentar novamente"; @@ -173,7 +173,7 @@ "notifications.quickReplyFailed.title" = "Mensagem não enviada"; /* Notification body when a quick reply fails - %@ is the contact or channel name */ -"notifications.quickReplyFailed.body" = "A resposta para %@ não pôde ser enviada."; +"notifications.quickReplyFailed.body" = "Não foi possível enviar a resposta para %@."; /* Fallback display name for a discovered contact with no advertised name */ "notifications.discovery.unknownContact" = "Contacto desconhecido"; @@ -187,7 +187,7 @@ "accessibility.connection.deviceConnectionLost" = "Ligação ao dispositivo perdida"; /* VoiceOver announcement when device reconnects */ -"accessibility.connection.deviceReconnected" = "Dispositivo religado"; +"accessibility.connection.deviceReconnected" = "Ligação restabelecida"; /* VoiceOver signal-strength descriptors for the device picker signal bars */ "accessibility.signalStrength.weak" = "Sinal fraco"; @@ -208,7 +208,7 @@ "error.meshCore.parseError" = "Falha ao analisar a resposta do dispositivo: %@"; /* Location: MeshCoreError+UserFacingMessage.swift - No active connection to the radio */ -"error.meshCore.notConnected" = "Não ligado ao dispositivo."; +"error.meshCore.notConnected" = "Sem ligação ao dispositivo."; /* Location: MeshCoreError+UserFacingMessage.swift - A command failed on the device - %@ is the failure reason */ "error.meshCore.commandFailed" = "O comando falhou: %@"; @@ -295,10 +295,10 @@ "error.ble.connectionTimeout" = "A ligação expirou. Tente novamente."; /* Location: BLEError+UserFacingMessage.swift - No BLE device connected */ -"error.ble.notConnected" = "Não ligado a um dispositivo."; +"error.ble.notConnected" = "Sem ligação ao dispositivo."; /* Location: BLEError+UserFacingMessage.swift - Required BLE characteristic missing */ -"error.ble.characteristicNotFound" = "Não foi possível comunicar com o dispositivo. Tente religar."; +"error.ble.characteristicNotFound" = "Não foi possível comunicar com o dispositivo. Tente restabelecer a ligação."; /* Location: BLEError+UserFacingMessage.swift - BLE write failed - %@ is the failure detail */ "error.ble.writeError" = "Falha ao enviar dados: %@"; @@ -316,7 +316,7 @@ "error.ble.pairingFailed" = "Falha no emparelhamento Bluetooth: %@"; /* Location: BLEError+UserFacingMessage.swift - Radio already in use by another app */ -"error.ble.deviceConnectedToOtherApp" = "Este dispositivo está ligado a outro app. Apenas um app pode usar um rádio mesh de cada vez para evitar problemas de comunicação."; +"error.ble.deviceConnectedToOtherApp" = "Este dispositivo está ligado a outra app. Apenas uma app pode usar um rádio mesh de cada vez para evitar problemas de comunicação."; /* Location: ConnectionError+UserFacingMessage.swift - Connection failed - %@ is the failure reason */ "error.connection.connectionFailed" = "Falha na ligação: %@"; @@ -325,7 +325,7 @@ "error.connection.deviceNotFound" = "Dispositivo não encontrado"; /* Location: ConnectionError+UserFacingMessage.swift - No active device connection */ -"error.connection.notConnected" = "Não ligado ao dispositivo"; +"error.connection.notConnected" = "Sem ligação ao dispositivo"; /* Location: ConnectionError+UserFacingMessage.swift - Post-connect device initialization failed - %@ is the failure reason */ "error.connection.initializationFailed" = "Falha na inicialização do dispositivo: %@"; @@ -337,7 +337,7 @@ "error.wifi.connectionTimeout" = "A ligação expirou. Verifique o hostname ou o endereço IP e certifique-se de que o dispositivo está acessível."; /* Location: WiFiTransportError+UserFacingMessage.swift - No active WiFi connection */ -"error.wifi.notConnected" = "Não ligado ao dispositivo."; +"error.wifi.notConnected" = "Sem ligação ao dispositivo."; /* Location: WiFiTransportError+UserFacingMessage.swift - WiFi send failed - %@ is the failure reason */ "error.wifi.sendFailed" = "Falha ao enviar dados: %@"; @@ -358,7 +358,7 @@ "error.accessorySetup.sessionNotActive" = "O Bluetooth não está pronto. Certifique-se de que o Bluetooth está ativado e tente novamente."; /* Location: AccessorySetupKitError+UserFacingMessage.swift - AccessorySetupKit session invalidated */ -"error.accessorySetup.sessionInvalidated" = "A sessão Bluetooth terminou inesperadamente. Reinicie o app."; +"error.accessorySetup.sessionInvalidated" = "A sessão Bluetooth terminou inesperadamente. Reinicie a app."; /* Location: AccessorySetupKitError+UserFacingMessage.swift - User dismissed the accessory picker */ "error.accessorySetup.pickerDismissed" = "A seleção do dispositivo foi cancelada."; @@ -382,7 +382,7 @@ "error.accessorySetup.connectionFailed" = "Não foi possível ligar ao dispositivo. Tente novamente."; /* Location: ContactServiceError+UserFacingMessage.swift - No active connection to the radio */ -"error.contactService.notConnected" = "Não ligado ao rádio"; +"error.contactService.notConnected" = "Sem ligação ao rádio"; /* Location: ContactServiceError+UserFacingMessage.swift - Contact operation message failed to send */ "error.contactService.sendFailed" = "Falha ao enviar mensagem"; @@ -397,13 +397,13 @@ "error.contactService.contactNotFound" = "Contacto não encontrado no dispositivo"; /* Location: ContactServiceError+UserFacingMessage.swift - Radio node list has no free slots */ -"error.contactService.contactTableFull" = "A lista de nodos do dispositivo está cheia"; +"error.contactService.contactTableFull" = "A lista de nós do dispositivo está cheia"; /* Location: ContactServiceError+UserFacingMessage.swift - Node advertisement missing or stale, so the node cannot be shared */ -"error.contactService.shareContactUnavailable" = "Não foi possível partilhar o nodo. O Advert do nodo pode estar em falta ou desatualizado."; +"error.contactService.shareContactUnavailable" = "Não foi possível partilhar o nó. O Advert do nó pode estar em falta ou desatualizado."; /* Location: MessageServiceError+UserFacingMessage.swift - No active connection to the radio */ -"error.messageService.notConnected" = "Não ligado ao dispositivo."; +"error.messageService.notConnected" = "Sem ligação ao dispositivo."; /* Location: MessageServiceError+UserFacingMessage.swift - Recipient contact missing from the database */ "error.messageService.contactNotFound" = "Contacto não encontrado."; @@ -421,7 +421,7 @@ "error.messageService.messageTooLong" = "A mensagem excede o comprimento máximo permitido."; /* Location: ChannelServiceError+UserFacingMessage.swift - No active connection to the radio */ -"error.channelService.notConnected" = "Não ligado ao dispositivo."; +"error.channelService.notConnected" = "Sem ligação ao dispositivo."; /* Location: ChannelServiceError+UserFacingMessage.swift - Channel missing from the database */ "error.channelService.channelNotFound" = "Canal não encontrado."; @@ -463,16 +463,16 @@ "error.chatSendQueue.persistFailed" = "Falha ao colocar a mensagem na fila de envio: %@"; /* Location: ChatSendQueueServiceError+UserFacingMessage.swift - No active connection to the radio */ -"error.chatSendQueue.notConnected" = "Não ligado ao dispositivo."; +"error.chatSendQueue.notConnected" = "Sem ligação ao dispositivo."; /* Location: MessagePollingError+UserFacingMessage.swift - No active connection to the radio */ -"error.messagePolling.notConnected" = "Não ligado ao dispositivo."; +"error.messagePolling.notConnected" = "Sem ligação ao dispositivo."; /* Location: MessagePollingError+UserFacingMessage.swift - Fetching queued messages from the radio failed */ "error.messagePolling.pollingFailed" = "Falha ao obter as mensagens em espera."; /* Location: AdvertisementError+UserFacingMessage.swift - No active connection to the radio */ -"error.advertisement.notConnected" = "Não ligado ao dispositivo."; +"error.advertisement.notConnected" = "Sem ligação ao dispositivo."; /* Location: AdvertisementError+UserFacingMessage.swift - Self-advertisement broadcast failed */ "error.advertisement.sendFailed" = "Falha ao enviar o Advert."; @@ -481,7 +481,7 @@ "error.advertisement.invalidResponse" = "Resposta inválida do dispositivo."; /* Location: RemoteNodeError+UserFacingMessage.swift - No active connection to the mesh radio */ -"error.remoteNode.notConnected" = "Não ligado ao dispositivo mesh"; +"error.remoteNode.notConnected" = "Sem ligação ao dispositivo mesh"; /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node login failed */ "error.remoteNode.loginFailed" = "Falha ao iniciar sessão."; @@ -490,7 +490,7 @@ "error.remoteNode.sendFailed" = "Falha no envio."; /* Location: RemoteNodeError+UserFacingMessage.swift - Malformed response from the remote node */ -"error.remoteNode.invalidResponse" = "Resposta inválida do nodo remoto"; +"error.remoteNode.invalidResponse" = "Resposta inválida do nó remoto"; /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node rejected the operation */ "error.remoteNode.permissionDenied" = "Permissão recusada"; @@ -499,16 +499,16 @@ "error.remoteNode.timeout" = "O pedido expirou"; /* Location: RemoteNodeError+UserFacingMessage.swift - No stored session for the remote node */ -"error.remoteNode.sessionNotFound" = "Sessão do nodo remoto não encontrada"; +"error.remoteNode.sessionNotFound" = "Sessão do nó remoto não encontrada"; /* Location: RemoteNodeError+UserFacingMessage.swift - Saved password missing from the keychain */ "error.remoteNode.passwordNotFound" = "Palavra-passe não encontrada no Porta-chaves"; /* Location: RemoteNodeError+UserFacingMessage.swift - Keep-alive needs a direct path but the route is flood-routed */ -"error.remoteNode.floodRouted" = "O keep-alive requer um caminho de encaminhamento direto"; +"error.remoteNode.floodRouted" = "O keep-alive requer uma rota de encaminhamento direto"; /* Location: RemoteNodeError+UserFacingMessage.swift - Establishing a direct path to the node failed */ -"error.remoteNode.pathDiscoveryFailed" = "Falha ao estabelecer um caminho direto"; +"error.remoteNode.pathDiscoveryFailed" = "Falha ao estabelecer uma rota direta"; /* Location: RemoteNodeError+UserFacingMessage.swift - Remote node contact missing from the database */ "error.remoteNode.contactNotFound" = "Contacto não encontrado na base de dados"; @@ -520,7 +520,7 @@ "error.remoteNode.cancelled" = "Início de sessão cancelado"; /* Location: RoomServerError+UserFacingMessage.swift - No active connection to the radio */ -"error.roomServer.notConnected" = "Não ligado ao dispositivo."; +"error.roomServer.notConnected" = "Sem ligação ao dispositivo."; /* Location: RoomServerError+UserFacingMessage.swift - No stored session for the room server */ "error.roomServer.sessionNotFound" = "Sessão da sala não encontrada."; @@ -535,7 +535,7 @@ "error.roomServer.invalidResponse" = "Resposta inválida do dispositivo."; /* Location: BinaryProtocolError+UserFacingMessage.swift - No active connection to the radio */ -"error.binaryProtocol.notConnected" = "Não ligado ao dispositivo."; +"error.binaryProtocol.notConnected" = "Sem ligação ao dispositivo."; /* Location: BinaryProtocolError+UserFacingMessage.swift - Binary request failed to send */ "error.binaryProtocol.sendFailed" = "Falha ao enviar o pedido."; @@ -559,7 +559,7 @@ "error.persistence.channelNotFound" = "Canal não encontrado."; /* Location: PersistenceStoreError+UserFacingMessage.swift - Remote node session row missing from the database */ -"error.persistence.remoteNodeSessionNotFound" = "Sessão do nodo remoto não encontrada."; +"error.persistence.remoteNodeSessionNotFound" = "Sessão do nó remoto não encontrada."; /* Location: PersistenceStoreError+UserFacingMessage.swift - Database save failed - %@ is the failure reason */ "error.persistence.saveFailed" = "Falha ao guardar: %@"; @@ -571,7 +571,7 @@ "error.persistence.invalidData" = "Dados inválidos."; /* Location: SyncCoordinatorError+UserFacingMessage.swift - No active connection to the radio */ -"error.syncCoordinator.notConnected" = "Não ligado ao dispositivo."; +"error.syncCoordinator.notConnected" = "Sem ligação ao dispositivo."; /* Location: SyncCoordinatorError+UserFacingMessage.swift - Full sync failed - %@ is the failure reason */ "error.syncCoordinator.syncFailed" = "Falha na sincronização: %@"; diff --git a/MC1/Resources/Localization/pt.lproj/Map.strings b/MC1/Resources/Localization/pt.lproj/Map.strings index 15f679f49..8c7e173f8 100644 --- a/MC1/Resources/Localization/pt.lproj/Map.strings +++ b/MC1/Resources/Localization/pt.lproj/Map.strings @@ -9,7 +9,7 @@ // MARK: - Common /* Location: MapView.swift - Purpose: Done button for sheets */ -"map.common.done" = "OK"; +"map.common.done" = "Concluído"; // MARK: - Map Controls @@ -93,16 +93,16 @@ "map.detail.longitude" = "Longitude"; /* Location: MapView.swift ContactDetailSheet - Purpose: Section header for outbound path info */ -"map.detail.section.outboundPath" = "Caminho de saída"; +"map.detail.section.outboundPath" = "Rota de saída"; /* Location: MapView.swift ContactDetailSheet - Purpose: Label for routing type */ "map.detail.routing" = "Encaminhamento"; /* Location: MapView.swift ContactDetailSheet - Purpose: Flood routing type value */ -"map.detail.routingFlood" = "Flood"; +"map.detail.routingFlood" = "Difusão"; /* Location: MapView.swift ContactDetailSheet - Purpose: Label for path length */ -"map.detail.pathLength" = "Comprimento do caminho"; +"map.detail.pathLength" = "Comprimento da rota"; /* Location: MapView.swift ContactDetailSheet - Purpose: Path length value with hop count */ "map.detail.hops" = "%d saltos"; @@ -163,13 +163,13 @@ "map.callout.discovered" = "Descoberto"; /* Location: DiscoveredNodeDetailSheet.swift - Purpose: Navigation title for discovered node detail */ -"map.discoveredDetail.title" = "Nodo descoberto"; +"map.discoveredDetail.title" = "Nó descoberto"; /* Location: DiscoveredNodeDetailSheet.swift - Purpose: Primary action to add discovered node to the radio table */ -"map.discoveredDetail.add" = "Adicionar aos nodos"; +"map.discoveredDetail.add" = "Adicionar aos nós"; /* Location: DiscoveredNodeCalloutContent.swift - Purpose: Accessibility label for discovered pins */ -"map.pin.accessibility.discovered" = "Descoberto, não está na lista de nodos"; +"map.pin.accessibility.discovered" = "Descoberto, não está na lista de nós"; // MARK: - Contact Annotation diff --git a/MC1/Resources/Localization/pt.lproj/Onboarding.strings b/MC1/Resources/Localization/pt.lproj/Onboarding.strings index 7b6beb8a5..4394e29c9 100644 --- a/MC1/Resources/Localization/pt.lproj/Onboarding.strings +++ b/MC1/Resources/Localization/pt.lproj/Onboarding.strings @@ -25,13 +25,13 @@ "permissions.title" = "Algumas permissões"; /* Location: PermissionsView.swift - Subtitle encouraging notification permission */ -"permissions.subtitle" = "As duas são opcionais. É possível mudar de ideia em Definições a qualquer momento."; +"permissions.subtitle" = "As duas são opcionais. É possível mudar de ideias nas Definições a qualquer momento."; /* Location: PermissionsView.swift - Permission card title for notifications */ "permissions.notifications.title" = "Notificações"; /* Location: PermissionsView.swift - Permission card description for notifications */ -"permissions.notifications.description" = "Receba alertas de novas mensagens e bateria fraca, mesmo com o app fechado."; +"permissions.notifications.description" = "Receba alertas de novas mensagens e bateria fraca, mesmo com a app fechada."; /* Location: PermissionsView.swift - Permission card title for location */ "permissions.location.title" = "Localização"; @@ -67,7 +67,7 @@ "deviceScan.title" = "Emparelhe o dispositivo"; /* Location: DeviceScanView.swift - Subtitle with pairing instructions */ -"deviceScan.subtitle" = "Ligue a alimentação do rádio, desligue-o de todas as outras apps e dispositivos e toque em Adicionar dispositivo."; +"deviceScan.subtitle" = "Ligue o rádio, desemparelhe-o de todas as outras apps e dispositivos e toque em Adicionar dispositivo."; /* Location: DeviceScanView.swift - Message shown when device is already paired */ "deviceScan.alreadyPaired" = "O dispositivo já está emparelhado"; @@ -91,7 +91,7 @@ "deviceScan.addDevice" = "Adicionar dispositivo"; /* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ -"deviceScan.retryConnection" = "Tentar ligação novamente"; +"deviceScan.retryConnection" = "Tentar ligar novamente"; /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "Ajuda"; @@ -106,8 +106,8 @@ "deviceScan.demoModeAlert.message" = "Agora é possível continuar sem um dispositivo. Ative ou desative o modo demo em Definições a qualquer momento."; /* Location: ConnectionUIState.presentPairingFailure(_:) - Pairing failure alert messages */ -"deviceScan.error.authenticationFailed" = "O emparelhamento guardado com este rádio deixou de funcionar. Toque em Remover e tentar novamente para emparelhar de novo no app; o rádio pode mostrar um novo código de emparelhamento."; -"deviceScan.error.pinRejected" = "O PIN não foi aceite. Verifique o PIN mostrado no dispositivo e tente novamente. Primeiro, o iOS pede a confirmação da remoção do emparelhamento que falhou."; +"deviceScan.error.authenticationFailed" = "O emparelhamento guardado com este rádio deixou de funcionar. Toque em Remover e tentar novamente para emparelhar de novo na app; o rádio pode mostrar um novo código de emparelhamento."; +"deviceScan.error.pinRejected" = "O PIN não foi aceite. Verifique o PIN mostrado no dispositivo e tente novamente. O iOS vai primeiro pedir para confirmar a remoção do emparelhamento anterior."; "deviceScan.error.connectionFailed" = "Não foi possível ligar ao dispositivo. Tente novamente ou remova-o se o problema continuar."; // MARK: - Troubleshooting Sheet @@ -131,13 +131,13 @@ "troubleshooting.basicChecks.restart" = "Reinicie o dispositivo MeshCore"; /* Location: DeviceScanView.swift - Section header for factory reset help */ -"troubleshooting.factoryReset.header" = "Repor o dispositivo para os valores de fábrica?"; +"troubleshooting.factoryReset.header" = "Repor o dispositivo para as definições de fábrica?"; /* Location: DeviceScanView.swift - Explanation about stale pairings */ -"troubleshooting.factoryReset.explanation" = "Se o dispositivo MeshCore for reposto para os valores de fábrica, o iOS ainda pode ter o emparelhamento antigo. Limpar isto em Definições do sistema permite que o dispositivo volte a aparecer."; +"troubleshooting.factoryReset.explanation" = "Se o dispositivo MeshCore for reposto para as definições de fábrica, o iOS ainda pode ter o emparelhamento antigo. Limpar isto em Definições do sistema permite que o dispositivo volte a aparecer."; /* Location: DeviceScanView.swift - Additional explanation about removal confirmation */ -"troubleshooting.factoryReset.confirmationNote" = "Ao tocar abaixo, será necessário confirmar a remoção do emparelhamento antigo. Isto é normal — permite que o dispositivo reposto volte a aparecer."; +"troubleshooting.factoryReset.confirmationNote" = "Ao tocar abaixo, terá de confirmar a remoção do emparelhamento antigo. Isto é normal: permite que o dispositivo reposto volte a aparecer."; /* Location: DeviceScanView.swift - Button to clear previous pairing */ "troubleshooting.factoryReset.clearPairing" = "Limpar emparelhamento anterior"; @@ -167,7 +167,7 @@ "troubleshooting.stillNotAppearing.header" = "Ainda não aparece?"; /* Location: TroubleshootingSheet.swift - Body text explaining backup and reflash steps */ -"troubleshooting.stillNotAppearing.body" = "Se já tentou tudo acima, faça uma cópia de segurança da configuração do rádio no app MeshCore de Liam Cottle e depois apague o rádio e volte a gravar o firmware no computador em https://flasher.meshcore.io"; +"troubleshooting.stillNotAppearing.body" = "Se já tentou tudo acima, faça uma cópia de segurança da configuração do rádio na app MeshCore de Liam Cottle e depois apague e volte a instalar o firmware a partir do computador em https://flasher.meshcore.io"; // MARK: - Mesh Animation View @@ -182,7 +182,7 @@ "noDevice.sheet.title" = "Dê uma vista de olhos"; /* Location: NoDeviceSheet.swift - Sheet body */ -"noDevice.sheet.body" = "É necessário um rádio emparelhado para enviar e receber mensagens. Explore o app por agora e emparelhe a qualquer momento em Definições."; +"noDevice.sheet.body" = "É necessário um rádio emparelhado para enviar e receber mensagens. Explore a app por agora e emparelhe a qualquer momento em Definições."; /* Location: NoDeviceSheet.swift - Primary CTA */ "noDevice.sheet.confirm" = "Continuar"; @@ -203,7 +203,7 @@ "preset.subtitle.locale" = "Escolha uma predefinição para o rádio."; /* Location: PresetStepView.swift - Footer help line pointing users to the MeshCore Discord */ -"preset.discordHelp" = "Não sabe qual predefinição escolher? Entre no Discord oficial do MeshCore e pergunte! [https://meshcore.gg](https://meshcore.gg)"; +"preset.discordHelp" = "Não sabe que predefinição escolher? Entre no Discord oficial do MeshCore e pergunte! [https://meshcore.gg](https://meshcore.gg)"; /* Location: PresetStepView.swift - Apply CTA "Use %@" */ "preset.use" = "Usar %@"; @@ -215,7 +215,7 @@ "preset.alreadyConfigured.subtitle" = "O rádio já está em %@, a predefinição recomendada para %@."; /* Location: PresetStepView.swift - Already-configured primary CTA */ -"preset.alreadyConfigured.done" = "OK"; +"preset.alreadyConfigured.done" = "Concluído"; /* Location: PresetStepView.swift - Already-configured secondary link */ "preset.alreadyConfigured.choose" = "Escolher outra predefinição"; @@ -277,7 +277,7 @@ "region.title" = "Escolha a região"; /* Location: RegionStepView.swift - Subtitle for the region step */ -"region.subtitle" = "Serão mostradas predefinições que funcionam na sua área."; +"region.subtitle" = "Só aparecem as predefinições que funcionam na sua área."; /* Location: RegionStepView.swift - "Detected" tag */ "region.detected.tag" = "Detetada"; @@ -298,7 +298,7 @@ "region.resolving" = "A procurar a região…"; /* Location: RegionStepView.swift - Error shown when user-initiated retry of "Use my location" cannot resolve a region */ -"region.useMyLocation.failure" = "A pesquisa de localização não devolveu uma região. Escolha manualmente abaixo."; +"region.useMyLocation.failure" = "Não foi possível determinar a região a partir da sua localização. Escolha manualmente abaixo."; // MARK: - Device Scanner Sheet (macOS) diff --git a/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings index 42ba791c2..f5ec4866b 100644 --- a/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings @@ -18,7 +18,7 @@ "remoteNodes.auth.cancel" = "Cancelar"; /* Location: NodeAuthenticationSheet.swift - Node details section header */ -"remoteNodes.auth.nodeDetails" = "Detalhes do nodo"; +"remoteNodes.auth.nodeDetails" = "Detalhes do nó"; /* Location: NodeAuthenticationSheet.swift - Name label */ "remoteNodes.auth.name" = "Nome"; @@ -57,24 +57,24 @@ "remoteNodes.auth.connect" = "Ligar"; /* Location: NodeAuthenticationSheet.swift - Path section header */ -"remoteNodes.auth.path" = "Caminho"; +"remoteNodes.auth.path" = "Rota"; /* Location: NodeAuthenticationSheet.swift - Flood routing toggle */ -"remoteNodes.auth.floodRouting" = "Encaminhamento Flood"; -"remoteNodes.auth.floodRetryStatus" = "Sem resposta pelo caminho conhecido. A tentar novamente com encaminhamento flood."; -"remoteNodes.auth.floodFooter" = "O encaminhamento flood é mais lento e usa mais tempo no ar da rede. Utilize-o quando o caminho conhecido não funcionar a partir da localização atual. Um novo caminho é aprendido automaticamente após iniciar sessão."; +"remoteNodes.auth.floodRouting" = "Encaminhamento por difusão"; +"remoteNodes.auth.floodRetryStatus" = "Sem resposta pela rota conhecida. A tentar novamente com encaminhamento por difusão."; +"remoteNodes.auth.floodFooter" = "O encaminhamento por difusão é mais lento e usa mais tempo de antena da rede. Utilize-o quando a rota conhecida não funcionar a partir da localização atual. Uma nova rota é aprendida automaticamente após iniciar sessão."; /* Location: NodeAuthenticationSheet.swift - Path section footer when stored path exists */ -"remoteNodes.auth.pathFooter" = "Utilize o caminho conhecido até este nodo, ou mude para o encaminhamento flood para que a rede encontre um caminho."; +"remoteNodes.auth.pathFooter" = "Utilize a rota conhecida até este nó, ou mude para o encaminhamento por difusão e deixe que a rede encontre outra."; /* Location: NodeAuthenticationSheet.swift - Path hop whose repeater is not known */ "remoteNodes.auth.pathHopUnknown" = ""; /* Location: NodeAuthenticationSheet.swift - Label when no route is set */ -"remoteNodes.auth.noRouteSet" = "Nenhum caminho definido"; +"remoteNodes.auth.noRouteSet" = "Nenhuma rota definida"; /* Location: NodeAuthenticationSheet.swift - Path section footer when no route is set */ -"remoteNodes.auth.noRouteFooter" = "Nenhum caminho conhecido até este nodo. A rede irá encontrar um caminho automaticamente."; +"remoteNodes.auth.noRouteFooter" = "Nenhuma rota conhecida até este nó. A rede irá encontrar uma automaticamente."; /* Location: NodeAuthenticationSheet.swift - Accessibility announcement for countdown */ "remoteNodes.auth.secondsRemainingAnnouncement" = "%d segundos restantes"; @@ -85,7 +85,7 @@ "remoteNodes.settings.title" = "Definições do repetidor"; /* Location: RepeaterSettingsView.swift - Done button (used in multiple places) */ -"remoteNodes.settings.done" = "OK"; +"remoteNodes.settings.done" = "Concluído"; /* Location: NodeSettingsView.swift - Settings tab label */ "remoteNodes.settings.tab.settings" = "Definições"; @@ -139,10 +139,10 @@ "remoteNodes.settings.bandwidthKHz" = "Largura de banda (kHz)"; /* Location: RepeaterSettingsView.swift - Accessibility label for bandwidth picker options - %@ is formatted bandwidth */ -"remoteNodes.settings.accessibility.bandwidthLabel" = "%@ quilohertz"; +"remoteNodes.settings.accessibility.bandwidthLabel" = "%@ kilohertz"; /* Location: RepeaterSettingsView.swift - Bandwidth accessibility hint */ -"remoteNodes.settings.bandwidthHint" = "Valores mais baixos aumentam o alcance, mas diminuem a velocidade"; +"remoteNodes.settings.bandwidthHint" = "Valores menores aumentam o alcance, mas diminuem a velocidade"; /* Location: RepeaterSettingsView.swift - Spreading factor label */ "remoteNodes.settings.spreadingFactor" = "Fator de espalhamento"; @@ -151,7 +151,7 @@ "remoteNodes.settings.accessibility.spreadingFactorLabel" = "Fator de espalhamento %d"; /* Location: RepeaterSettingsView.swift - Spreading factor accessibility hint */ -"remoteNodes.settings.spreadingFactorHint" = "Valores mais altos aumentam o alcance, mas diminuem a velocidade"; +"remoteNodes.settings.spreadingFactorHint" = "Valores maiores aumentam o alcance, mas diminuem a velocidade"; /* Location: RepeaterSettingsView.swift - Coding rate label */ "remoteNodes.settings.codingRate" = "Taxa de codificação"; @@ -160,7 +160,7 @@ "remoteNodes.settings.accessibility.codingRateLabel" = "Taxa de codificação %d"; /* Location: RepeaterSettingsView.swift - Coding rate accessibility hint */ -"remoteNodes.settings.codingRateHint" = "Valores mais altos adicionam correção de erros, mas diminuem a velocidade"; +"remoteNodes.settings.codingRateHint" = "Valores maiores adicionam correção de erros, mas diminuem a velocidade"; /* Location: RepeaterSettingsView.swift - TX power label */ "remoteNodes.settings.txPowerDbm" = "Potência TX (dBm)"; @@ -195,7 +195,7 @@ "remoteNodes.settings.contactInfoPlaceholder" = "Frequência, nome do operador, sítio web..."; /* Location: RepeaterSettingsView.swift - Contact info footer */ -"remoteNodes.settings.contactInfoFooter" = "Dados de contacto públicos visíveis para outros nodos. Utilize quebras de linha para separar os campos."; +"remoteNodes.settings.contactInfoFooter" = "Dados de contacto públicos visíveis para outros nós. Utilize quebras de linha para separar os campos."; /* Location: RepeaterSettingsView.swift - Apply contact info button */ "remoteNodes.settings.applyContactInfo" = "Aplicar informações de contacto"; @@ -214,13 +214,13 @@ "remoteNodes.settings.min" = "min"; /* Location: RepeaterSettingsView.swift - Advert interval (flood) label */ -"remoteNodes.settings.advertIntervalFlood" = "Intervalo de Advert (flood)"; +"remoteNodes.settings.advertIntervalFlood" = "Intervalo de Advert (difusão)"; /* Location: RepeaterSettingsView.swift - Hours unit */ "remoteNodes.settings.hrs" = "h"; /* Location: RepeaterSettingsView.swift - Max flood hops label */ -"remoteNodes.settings.maxFloodHops" = "Máximo de saltos Flood"; +"remoteNodes.settings.maxFloodHops" = "Máximo de saltos por difusão"; /* Location: RepeaterSettingsView.swift - Hops unit */ "remoteNodes.settings.hops" = "saltos"; @@ -253,7 +253,7 @@ "remoteNodes.settings.identityFooter" = "Nome do repetidor e coordenadas GPS para apresentação no mapa."; /* Location: RepeaterSettingsView.swift - Behavior section footer */ -"remoteNodes.settings.behaviorFooter" = "Intervalos de Advert, saltos flood e modo repetidor."; +"remoteNodes.settings.behaviorFooter" = "Intervalos de Advert, saltos por difusão e modo repetidor."; /* Location: RepeaterSettingsView.swift - Device actions section header */ "remoteNodes.settings.deviceActions" = "Ações do dispositivo"; @@ -350,10 +350,10 @@ "remoteNodes.settings.regionsFooter" = "Guarde no repetidor para manter as alterações após os reinícios."; /* Location: RepeaterSettingsView.swift - Unscoped region display name */ -"remoteNodes.settings.regions.allTraffic" = "Sem âmbito"; +"remoteNodes.settings.regions.allTraffic" = "Sem scope"; /* Location: RepeaterSettingsView.swift - Unscoped region with asterisk display */ -"remoteNodes.settings.regions.allTrafficWildcard" = "* (Sem âmbito)"; +"remoteNodes.settings.regions.allTrafficWildcard" = "* (Sem scope)"; /* Location: RepeaterSettingsView.swift - Home region picker label */ "remoteNodes.settings.regions.homeRegion" = "Região home"; @@ -361,7 +361,7 @@ /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ /* Location: RepeaterSettingsView.swift - Accessibility hint for flood toggle */ -"remoteNodes.settings.regions.floodToggleHint" = "Quando desligado, os pacotes flood desta região são descartados"; +"remoteNodes.settings.regions.floodToggleHint" = "Quando desligado, os pacotes por difusão desta região são descartados"; /* Location: RepeaterSettingsView.swift - Add region button */ "remoteNodes.settings.regions.addRegion" = "Adicionar região"; @@ -410,8 +410,8 @@ /* Location: RepeaterStatusView.swift - Guest mode badge in header */ "remoteNodes.status.guestMode" = "Modo convidado"; -"remoteNodes.status.clockAhead" = "O relógio deste nodo está %@ adiantado em relação ao seu rádio. Um nodo com o relógio impreciso pode ignorar comandos ou mostrar horas de mensagem erradas."; -"remoteNodes.status.clockBehind" = "O relógio deste nodo está %@ atrasado em relação ao seu rádio. Um nodo com o relógio impreciso pode ignorar comandos ou mostrar horas de mensagem erradas."; +"remoteNodes.status.clockAhead" = "O relógio deste nó está %@ adiantado em relação ao seu rádio. Um nó com o relógio impreciso pode ignorar comandos ou mostrar horas erradas nas mensagens."; +"remoteNodes.status.clockBehind" = "O relógio deste nó está %@ atrasado em relação ao seu rádio. Um nó com o relógio impreciso pode ignorar comandos ou mostrar horas erradas nas mensagens."; /* Location: RepeaterStatusView.swift - Owner info section label */ "remoteNodes.status.ownerInfo" = "Informações de contacto"; @@ -432,10 +432,10 @@ "remoteNodes.status.uptime" = "Tempo de atividade"; /* Location: SharedNodeViews.swift - Airtime label */ -"remoteNodes.status.airtime" = "Tempo no ar"; +"remoteNodes.status.airtime" = "Tempo de antena"; /* Location: SharedNodeViews.swift - Airtime percent label */ -"remoteNodes.status.airtimePercent" = "Tempo no ar %%"; +"remoteNodes.status.airtimePercent" = "Tempo de antena %%"; /* Location: RepeaterStatusView.swift - Last RSSI label */ "remoteNodes.status.lastRssi" = "Último RSSI"; @@ -459,11 +459,11 @@ /* Location: SharedNodeStatusViews.swift - Sent (direct) packets label */ "remoteNodes.status.sentDirect" = "Enviados (direto)"; /* Location: SharedNodeStatusViews.swift - Sent (flood) packets label */ -"remoteNodes.status.sentFlood" = "Enviados (Flood)"; +"remoteNodes.status.sentFlood" = "Enviados (Difusão)"; /* Location: SharedNodeStatusViews.swift - Received (direct) packets label */ "remoteNodes.status.receivedDirect" = "Recebidos (direto)"; /* Location: SharedNodeStatusViews.swift - Received (flood) packets label */ -"remoteNodes.status.receivedFlood" = "Recebidos (Flood)"; +"remoteNodes.status.receivedFlood" = "Recebidos (Difusão)"; /* Location: SharedNodeStatusViews.swift - Duplicate packets label */ "remoteNodes.status.duplicates" = "Duplicados"; @@ -507,7 +507,7 @@ "remoteNodes.status.possibleMatchTitle" = "Possível correspondência"; /* Location: RepeaterStatusView.swift - Explanation of what a possible match means */ -"remoteNodes.status.possibleMatchExplanation" = "Vários nodos partilham este prefixo. O nome apresentado pode não estar correto."; +"remoteNodes.status.possibleMatchExplanation" = "Vários nós partilham este prefixo. O nome apresentado pode não estar correto."; // MARK: - Repeater Status ViewModel Messages @@ -572,7 +572,7 @@ "remoteNodes.status.sensor.switchValue" = "Interruptor"; /* Location: RepeaterStatusView.swift - Neighbors section footer */ -"remoteNodes.status.neighborsFooter" = "Outros nodos descobertos por este repetidor e a qualidade do respetivo sinal."; +"remoteNodes.status.neighborsFooter" = "Outros nós descobertos por este repetidor e a qualidade do respetivo sinal."; /* Location: RepeaterStatusView.swift - Discover neighbours button label */ "remoteNodes.status.discoverNeighbors" = "Descobrir vizinhos"; @@ -682,7 +682,7 @@ /* Location: RadioMetricCharts.swift - Direct packet series legend */ "remoteNodes.history.direct" = "Direto"; /* Location: RadioMetricCharts.swift - Flood packet series legend */ -"remoteNodes.history.flood" = "Flood"; +"remoteNodes.history.flood" = "Difusão"; /* Location: RadioMetricCharts.swift - Duplicates chart title, under the Packets group header */ "remoteNodes.history.duplicates" = "Duplicados"; /* Location: RadioMetricCharts.swift - Section header grouping the packet-count charts */ @@ -728,7 +728,7 @@ "remoteNodes.history.locationReportsHeader" = "Histórico de localização"; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when no snapshots exist */ -"remoteNodes.history.noSnapshotsMessage" = "Ligue a este nodo pelo menos uma vez para ver o histórico."; +"remoteNodes.history.noSnapshotsMessage" = "Ligue a este nó pelo menos uma vez para ver o histórico."; /* Location: TelemetryHistoryOverviewView.swift - Purpose: Empty state when section data not captured */ "remoteNodes.history.sectionNotCaptured" = "Estes dados são capturados ao visualizar a secção %@ durante uma sessão de telemetria em direto."; @@ -751,7 +751,7 @@ "remoteNodes.room.publicMessage" = "Mensagem pública"; /* Location: RoomConversationView.swift - Read-only banner */ -"remoteNodes.room.viewOnlyBanner" = "Só de leitura - entre como membro para publicar"; +"remoteNodes.room.viewOnlyBanner" = "Só de leitura. Entre como membro para publicar"; /* Location: RoomConversationView.swift - Hint text for read-only banner */ "remoteNodes.room.viewOnlyHint" = "Toque para iniciar sessão"; @@ -850,7 +850,7 @@ "remoteNodes.roomSettings.applyRoomSettings" = "Aplicar definições da sala"; /* Location: RoomSettingsView.swift - Room behavior section footer */ -"remoteNodes.roomSettings.behaviorFooter" = "Intervalos de Advert e saltos flood."; +"remoteNodes.roomSettings.behaviorFooter" = "Intervalos de Advert e saltos por difusão."; /* Location: RoomSettingsView.swift - Identity section footer */ /* Location: RoomSettingsView.swift - Reboot confirmation title */ @@ -893,13 +893,13 @@ "remoteNodes.nodeCli.helpClear" = " clear\n Limpar o terminal"; /* Location: NodeCliViewModel.swift - Help entry for 'clear stats' command */ -"remoteNodes.nodeCli.helpClearStats" = " clear stats\n Repor as estatísticas do nodo"; +"remoteNodes.nodeCli.helpClearStats" = " clear stats\n Repor as estatísticas do nó"; /* Location: NodeCliViewModel.swift - Help entry for 'reboot' command */ -"remoteNodes.nodeCli.helpReboot" = " reboot\n Reiniciar este nodo"; +"remoteNodes.nodeCli.helpReboot" = " reboot\n Reiniciar este nó"; /* Location: NodeCliViewModel.swift - Passthrough note at end of help output */ -"remoteNodes.nodeCli.helpPassthrough" = "Qualquer outra entrada é enviada ao nodo."; +"remoteNodes.nodeCli.helpPassthrough" = "Qualquer outra entrada é enviada ao nó."; // MARK: - Shared @@ -907,7 +907,7 @@ "remoteNodes.cancel" = "Cancelar"; /* Location: Multiple files - Done button */ -"remoteNodes.done" = "OK"; +"remoteNodes.done" = "Concluído"; /* Location: Multiple files - Name label */ "remoteNodes.name" = "Nome"; diff --git a/MC1/Resources/Localization/pt.lproj/Settings.strings b/MC1/Resources/Localization/pt.lproj/Settings.strings index c1250f178..64ffceae2 100644 --- a/MC1/Resources/Localization/pt.lproj/Settings.strings +++ b/MC1/Resources/Localization/pt.lproj/Settings.strings @@ -58,7 +58,7 @@ // MARK: - Device Info View /* Navigation title for device info screen */ -"deviceInfo.title" = "Informações do dispositivo"; +"deviceInfo.title" = "Sobre o dispositivo"; /* Section header for connection status */ "deviceInfo.connection.header" = "Ligação"; @@ -97,10 +97,10 @@ "deviceInfo.capabilities.header" = "Capacidades"; /* Label for max nodes capability */ -"deviceInfo.maxNodes" = "Máximo de nodos"; +"deviceInfo.maxNodes" = "Capacidade máxima de nós"; /* Label for max channels capability */ -"deviceInfo.maxChannels" = "Máximo de canais"; +"deviceInfo.maxChannels" = "Capacidade máxima de canais"; /* Label for max TX power capability */ "deviceInfo.maxTxPower" = "Potência de TX máxima"; @@ -205,7 +205,7 @@ "locationPicker.clearLocation" = "Limpar localização"; /* Button to drop a pin at the map center */ -"locationPicker.dropPin" = "Colocar alfinete no centro"; +"locationPicker.dropPin" = "Colocar ponto no centro"; /* Label for latitude display */ "locationPicker.latitude" = "Latitude:"; @@ -271,10 +271,10 @@ "advancedRadio.bandwidth" = "Largura de banda (kHz)"; /* Label for spreading factor picker */ -"advancedRadio.spreadingFactor" = "Fator de espalhamento"; +"advancedRadio.spreadingFactor" = "Fator de espalhamento (SF)"; /* Label for coding rate picker */ -"advancedRadio.codingRate" = "Taxa de codificação"; +"advancedRadio.codingRate" = "Taxa de codificação (CR)"; /* Label for TX power input */ "advancedRadio.txPower" = "Potência de TX (dBm)"; @@ -283,7 +283,7 @@ "advancedRadio.txPowerPlaceholder" = "dBm"; /* Accessibility label for bandwidth picker options - %@ is formatted bandwidth value */ -"advancedRadio.accessibility.bandwidthLabel" = "%@ quilohertz"; +"advancedRadio.accessibility.bandwidthLabel" = "%@ kilohertz"; /* Accessibility label for spreading factor picker options - %d is the factor value */ "advancedRadio.accessibility.spreadingFactorLabel" = "Fator de espalhamento %d"; @@ -313,12 +313,12 @@ "advancedRadio.repeatMode" = "Modo de repetidor"; /* Footer explaining repeat mode in advanced radio */ -"advancedRadio.repeatMode.footer" = "Cria um repetidor local numa frequência dedicada. Útil para trilhos e zonas remotas. Frequências válidas: 433, 869.495, 918 MHz."; +"advancedRadio.repeatMode.footer" = "Cria um repetidor local numa frequência dedicada. Útil para zonas remotas. Frequências válidas: 433, 869.495, 918 MHz."; // MARK: - Path Hash Mode Section /* Section header for path hash mode */ -"pathHashMode.header" = "Tamanho do hash do caminho"; +"pathHashMode.header" = "Tamanho do hash da rota"; /* Label for path hash mode picker */ "pathHashMode.label" = "Tamanho do hash"; @@ -333,15 +333,15 @@ "pathHashMode.threeBytes" = "3 bytes"; /* Footer explaining path hash mode tradeoff */ -"pathHashMode.footer" = "Hashes maiores reduzem colisões de routing, mas limitam o número máximo de saltos por caminho. Os repetidores com firmware anterior a 1.14.0 não repetem mensagens com hash maior que 1 byte."; +"pathHashMode.footer" = "Hashes maiores reduzem colisões de encaminhamento, mas limitam o número máximo de saltos por rota. Os repetidores com firmware anterior a 1.14.0 não retransmitem mensagens com hash superior a 1 byte."; // MARK: - Default Flood Scope Section /* Section header for default flood scope picker */ -"defaultFloodScope.header" = "Âmbito de flood predefinido"; +"defaultFloodScope.header" = "Scope de difusão predefinido"; /* Footer explaining default flood scope */ -"defaultFloodScope.footer" = "Quando definido, o dispositivo aplica este âmbito aos envios flood, a menos que um âmbito específico do canal o substitua. O âmbito é guardado no dispositivo e mantém-se após reinícios."; +"defaultFloodScope.footer" = "Quando definido, o dispositivo aplica este scope aos envios por difusão, a menos que um scope específico do canal o substitua. O scope é guardado no dispositivo e mantém-se após reinícios."; /* Option to clear the persisted default flood scope */ "defaultFloodScope.disabled" = "Nenhum"; @@ -445,7 +445,7 @@ // MARK: - Nodes Settings Section /* Section header for nodes settings */ -"nodes.header" = "Nodos"; +"nodes.header" = "Nós"; /* Label for auto-add mode picker */ "nodes.autoAddMode" = "Modo de adição automática"; @@ -454,7 +454,7 @@ "nodes.autoAddMode.manual" = "Manual"; /* Auto-add mode: manual description */ -"nodes.autoAddMode.manualDescription" = "Reveja todos os nodos em Descobrir antes de adicionar"; +"nodes.autoAddMode.manualDescription" = "Reveja todos os nós em Descobrir antes de adicionar"; /* Auto-add mode: selected types */ "nodes.autoAddMode.selectedTypes" = "Tipos selecionados"; @@ -466,7 +466,7 @@ "nodes.autoAddMode.all" = "Todos"; /* Auto-add mode: all description */ -"nodes.autoAddMode.allDescription" = "Adicionar automaticamente todos os nodos descobertos"; +"nodes.autoAddMode.allDescription" = "Adicionar automaticamente todos os nós descobertos"; /* Section header for auto-add types */ /* Toggle label for auto-add contacts */ @@ -483,7 +483,7 @@ "nodes.overwriteOldest" = "Substituir o mais antigo"; /* Description for overwrite oldest toggle */ -"nodes.overwriteOldestDescription" = "Quando o armazenamento estiver cheio, substitui o nodo não favorito mais antigo"; +"nodes.overwriteOldestDescription" = "Quando o armazenamento estiver cheio, substituir o nó não favorito mais antigo"; /* Picker label for max hop distance */ "nodes.maxHops" = "Limite máximo de saltos na adição automática"; @@ -501,15 +501,15 @@ "nodes.maxHops.hops" = "%d saltos"; /* Footer text when hop limit is active */ -"nodes.maxHops.footerActive" = "Os nodos além da distância de saltos selecionada não serão adicionados automaticamente."; +"nodes.maxHops.footerActive" = "Os nós acima do limite de saltos selecionado não serão adicionados automaticamente."; // MARK: - Auto-Remove Old Nodes Section /* Toggle label / section header for stale node cleanup */ -"nodes.staleCleanup.header" = "Remover automaticamente nodos antigos"; +"nodes.staleCleanup.header" = "Remover automaticamente nós antigos"; /* Label for threshold picker */ -"nodes.staleCleanup.threshold" = "Remover nodos mais antigos que"; +"nodes.staleCleanup.threshold" = "Remover nós mais antigos do que"; /* Picker placeholder when no threshold is selected */ "nodes.staleCleanup.select" = "Selecionar"; @@ -518,16 +518,16 @@ "nodes.staleCleanup.days" = "%d dias"; /* Footer when auto-remove is enabled (%d = day count) */ -"nodes.staleCleanup.footerEnabled" = "Os nodos não favoritos não modificados em %d dias são removidos automaticamente na ligação."; +"nodes.staleCleanup.footerEnabled" = "Os nós não favoritos sem alterações em %d dias são removidos automaticamente ao estabelecer ligação."; /* Footer describing the auto-remove feature (shown when toggle is off) */ -"nodes.staleCleanup.footerDisabled" = "Remove automaticamente nodos não favoritos que não foram modificados num período definido. Os favoritos nunca são removidos."; +"nodes.staleCleanup.footerDisabled" = "Remove automaticamente nós não favoritos sem alterações durante o período definido. Os favoritos nunca são removidos."; /* Footer when toggle is on but no threshold selected yet */ -"nodes.staleCleanup.footerSelect" = "Escolha durante quanto tempo manter os nodos. Os favoritos nunca são removidos."; +"nodes.staleCleanup.footerSelect" = "Escolha durante quanto tempo manter os nós. Os favoritos nunca são removidos."; /* Footer when enabled but device is disconnected */ -"nodes.staleCleanup.footerDisconnected" = "Irá verificar nodos antigos na próxima ligação. Os favoritos nunca são removidos."; +"nodes.staleCleanup.footerDisconnected" = "Irá verificar nós antigos ao estabelecer ligação. Os favoritos nunca são removidos."; /* Last cleanup date row (%@ = relative date) */ "nodes.staleCleanup.lastRun" = "Última verificação %@"; @@ -555,13 +555,13 @@ // MARK: - Danger Zone Section /* Section header for danger zone */ -"dangerZone.header" = "Zona de perigo"; +"dangerZone.header" = "Zona perigosa"; /* Button to forget/unpair the device */ "dangerZone.forgetDevice" = "Esquecer dispositivo"; /* Button to factory reset the device */ -"dangerZone.factoryReset" = "Repor o dispositivo de fábrica"; +"dangerZone.factoryReset" = "Repor definições de fábrica"; /* Text shown while resetting */ "dangerZone.resetting" = "A repor…"; @@ -582,7 +582,7 @@ "dangerZone.dialog.forget.deleteAll" = "Esquecer dispositivo e eliminar dados"; /* Alert title for factory reset confirmation */ -"dangerZone.alert.reset.title" = "Repor de fábrica"; +"dangerZone.alert.reset.title" = "Reposição de fábrica"; /* Button to confirm reset */ "dangerZone.alert.reset.confirm" = "Repor"; @@ -594,31 +594,31 @@ "dangerZone.error.servicesUnavailable" = "Serviços indisponíveis"; /* Button to remove non-favorite nodes */ -"dangerZone.removeUnfavorited" = "Remover nodos não favoritos"; +"dangerZone.removeUnfavorited" = "Remover nós não favoritos"; /* Text shown while removing unfavorited nodes */ "dangerZone.removing" = "A remover…"; /* Label shown after successful removal */ -"dangerZone.removed" = "Nodos removidos"; +"dangerZone.removed" = "Nós removidos"; /* Alert title for remove non-favorite confirmation */ -"dangerZone.alert.removeUnfavorited.title" = "Remover nodos não favoritos"; +"dangerZone.alert.removeUnfavorited.title" = "Remover nós não favoritos"; /* Button to confirm removal */ -"dangerZone.alert.removeUnfavorited.confirm" = "Remover nodos"; +"dangerZone.alert.removeUnfavorited.confirm" = "Remover nós"; /* Alert title for removal result */ "dangerZone.alert.removeUnfavorited.resultTitle" = "Resultado da remoção"; /* Alert message for remove unfavorited (%d = count) */ -"dangerZone.alert.removeUnfavorited.message" = "Isto irá eliminar permanentemente %d nodos não marcados como favoritos do dispositivo e da app, juntamente com as respetivas mensagens."; +"dangerZone.alert.removeUnfavorited.message" = "Isto irá eliminar permanentemente do dispositivo e da app %d nós não marcados como favoritos, juntamente com as respetivas mensagens."; /* Partial success message (%d = removed, %d = total) */ -"dangerZone.alert.removeUnfavorited.partial" = "Removidos %d de %d nodos. A ligação foi interrompida — toque novamente no botão para tentar remover os nodos restantes."; +"dangerZone.alert.removeUnfavorited.partial" = "Removidos %d de %d nós. A ligação foi interrompida. Toque novamente no botão para tentar remover os nós restantes."; /* Message when no non-favorite nodes to remove */ -"dangerZone.alert.removeUnfavorited.noneFound" = "Não há nodos não favoritos para remover."; +"dangerZone.alert.removeUnfavorited.noneFound" = "Não há nós não favoritos para remover."; // MARK: - Diagnostics Section @@ -660,7 +660,7 @@ "linkPreviews.toggle" = "Mostrar conteúdo de ligações"; /* Toggle label for showing link content in DMs */ -"linkPreviews.showInDMs" = "Mostrar em DMs"; +"linkPreviews.showInDMs" = "Mostrar em mensagens diretas"; /* Toggle label for showing link content in channels */ "linkPreviews.showInChannels" = "Mostrar em canais"; @@ -669,7 +669,7 @@ "linkPreviews.footer" = "As pré-visualizações de ligações e as imagens são obtidas através da ligação à Internet do telemóvel, não da mesh. Isto pode revelar o endereço IP ao servidor que aloja o conteúdo e pode usar dados móveis."; /* Footer note shown when Reduce Motion is enabled */ -"linkPreviews.reduceMotionNote" = "Reduzir movimento está ativado, por isso os GIFs não serão reproduzidos automaticamente."; +"linkPreviews.reduceMotionNote" = "A opção Reduzir movimento está ativada, por isso os GIFs não são reproduzidos automaticamente."; // MARK: - Inline Image Settings Section @@ -685,15 +685,15 @@ "mapPreviews.toggle" = "Mostrar miniaturas do mapa"; /* Footer explaining map preview privacy implications */ -"mapPreviews.footer" = "Mostrar miniaturas do mapa obtém mosaicos do mapa para a coordenada a partir de um servidor de terceiros, o que pode revelar o endereço IP."; +"mapPreviews.footer" = "Mostrar miniaturas do mapa vai buscar os mosaicos dessa coordenada a um servidor de terceiros, o que pode revelar o seu endereço IP."; // MARK: - Node Settings Section /* Section header for node settings */ -"node.header" = "Nodo"; +"node.header" = "Nó"; /* Label for node name */ -"node.name" = "Nome do nodo"; +"node.name" = "Nome do nó"; /* Default node name when unknown */ /* Button text to copy */ @@ -710,10 +710,10 @@ "node.shareLocationPublicly" = "Partilhar localização publicamente"; /* Footer explaining node visibility */ -"node.footer" = "O nome do nodo fica visível para outros utilizadores da mesh quando é partilhado."; +"node.footer" = "O nome do nó fica visível para outros utilizadores da mesh quando é partilhado."; /* Alert title for editing node name */ -"node.alert.editName.title" = "Editar nome do nodo"; +"node.alert.editName.title" = "Editar nome do nó"; // MARK: - Location Settings Section @@ -721,7 +721,7 @@ "location.header" = "Localização"; /* Footer for location settings section */ -"location.footer" = "Quando Partilhar localização publicamente está ativado, a localização será transmitida pela mesh ao enviar Adverts. Ativar Atualizar localização automaticamente atualiza a localização do dispositivo antes de enviar Adverts."; +"location.footer" = "Com a opção «Partilhar localização publicamente» ativada, a localização é transmitida na mesh ao enviar Adverts. A opção «Atualizar localização automaticamente» atualiza a posição do dispositivo antes de cada Advert."; /* Toggle label for auto-update location */ "location.autoUpdate" = "Atualizar localização automaticamente"; @@ -745,10 +745,10 @@ "location.deviceGps.footer" = "Liga ou desliga o GPS integrado do rádio. Guardar uma localização manual no mapa desliga o GPS do dispositivo."; /* Detail text when location is being shared publicly */ -"location.sharingPublicly" = "Partilha pública"; +"location.sharingPublicly" = "Partilhada publicamente"; /* Detail text when location is not being shared */ -"location.notSharing" = "Sem partilha"; +"location.notSharing" = "Não partilhada"; // MARK: - Notification Settings Section @@ -768,7 +768,7 @@ "notifications.newContactDiscovered" = "Novo contacto descoberto"; /* Toggle label for companion discovery notifications */ -"notifications.discoveryContact" = "Companion"; +"notifications.discoveryContact" = "Contacto"; /* Toggle label for repeater discovery notifications */ "notifications.discoveryRepeater" = "Repetidor"; @@ -808,7 +808,7 @@ "radio.repeatMode" = "Modo de repetidor"; /* Footer explaining repeat mode */ -"radio.repeatMode.footer" = "Cria um repetidor local numa frequência dedicada. Útil para trilhos e zonas remotas."; +"radio.repeatMode.footer" = "Cria um repetidor local numa frequência dedicada. Útil para zonas remotas."; /* Accessibility hint for repeat mode toggle */ "radio.repeatMode.accessibilityHint" = "Ativar isto irá desligar da rede mesh principal"; @@ -837,7 +837,7 @@ "telemetry.allowRequests" = "Permitir pedidos de telemetria"; /* Description for telemetry requests toggle */ -"telemetry.allowRequestsDescription" = "Necessário para que outros utilizadores possam traçar manualmente um caminho até si. Partilha o nível da bateria."; +"telemetry.allowRequestsDescription" = "Necessário para que outros utilizadores possam traçar manualmente uma rota até si. Partilha o nível da bateria."; /* Toggle label for including location in telemetry */ "telemetry.includeLocation" = "Incluir localização"; @@ -861,7 +861,7 @@ "telemetry.manageTrusted" = "Gerir contactos de confiança"; /* Footer explaining telemetry */ -"telemetry.footer" = "Quando ativado, outros nodos podem pedir os dados de telemetria do dispositivo."; +"telemetry.footer" = "Quando ativado, outros nós podem pedir os dados de telemetria do dispositivo."; // MARK: - WiFi Section @@ -896,7 +896,7 @@ "wifiEdit.footer" = "Alterar estes valores irá desligar e voltar a ligar ao novo endereço."; /* Text shown while reconnecting */ -"wifiEdit.reconnecting" = "A religar…"; +"wifiEdit.reconnecting" = "A restabelecer ligação…"; /* Button to save changes */ "wifiEdit.saveChanges" = "Guardar alterações"; @@ -944,27 +944,27 @@ /* Toggle label for showing the raw uncorrected wire send time on incoming messages */ /* Toggle label for showing routing path on incoming messages */ -"messages.showIncomingPath" = "Caminho de entrada"; +"messages.showIncomingPath" = "Rota"; /* Toggle label for showing hop count on incoming messages */ -"messages.showIncomingHopCount" = "Número de saltos de entrada"; +"messages.showIncomingHopCount" = "Número de saltos"; /* Toggle label for showing the radio region an incoming message was flooded under */ -"messages.showIncomingRegion" = "Região de entrada"; +"messages.showIncomingRegion" = "Região"; /* Footer explaining what the message display options show */ -"messages.footer" = "Mostra informações de routing e de tempo nos balões das mensagens recebidas."; +"messages.footer" = "Mostra informações de encaminhamento e de tempo nos balões das mensagens recebidas."; // MARK: - Direct Messages Settings Section /* Section header for direct message settings */ -"directMessages.header" = "DMs"; +"directMessages.header" = "Mensagens diretas"; /* Picker label for number of acknowledgments */ "directMessages.acknowledgments" = "Confirmações"; /* Footer explaining the acknowledgments setting */ -"directMessages.footer" = "Número de confirmações enviadas por DM. Use 2 para melhor confirmação de entrega em ligações pouco fiáveis."; +"directMessages.footer" = "Número de confirmações enviadas por mensagem direta. Use 2 para melhor confirmação de entrega em ligações pouco fiáveis."; // MARK: - BLE Status Indicator @@ -972,7 +972,7 @@ "bleStatus.sendZeroHopAdvert" = "Enviar Advert zero-hop"; /* Menu item to send a flood advertisement */ -"bleStatus.sendFloodAdvert" = "Enviar Advert flood"; +"bleStatus.sendFloodAdvert" = "Enviar Advert por difusão"; /* Menu item to change the connected device */ "bleStatus.changeDevice" = "Mudar de dispositivo"; @@ -981,7 +981,7 @@ "bleStatus.disconnect" = "Desligar"; /* Status shown when device is disconnected */ -"bleStatus.status.disconnected" = "Desligado"; +"bleStatus.status.disconnected" = "Sem ligação"; /* Status shown when device is connecting */ "bleStatus.status.connecting" = "A ligar…"; @@ -1019,7 +1019,7 @@ "configExport.sectionTitle" = "Configuração do dispositivo"; /* Section footer explaining config export/import */ -"configExport.sectionFooter" = "Exporte ou importe definições do dispositivo, canais e contactos como um ficheiro JSON."; +"configExport.sectionFooter" = "Exporte ou importe definições do dispositivo, canais e contactos através de um ficheiro JSON."; /* Export navigation row label */ "configExport.export" = "Exportar config"; @@ -1038,7 +1038,7 @@ "configExport.selectAll" = "Selecionar tudo"; /* Toggle labels for export sections */ -"configExport.nodeIdentity" = "Identidade do nodo (nome e chaves)"; +"configExport.nodeIdentity" = "Identidade do nó (nome e chaves)"; "configExport.nodeIdentity.description" = "Nome do dispositivo, chave pública e chave privada"; "configExport.radioSettings" = "Definições do rádio"; "configExport.radioSettings.description" = "Frequência, largura de banda, fator de espalhamento, taxa de codificação, potência de TX"; @@ -1105,7 +1105,7 @@ "configImport.importSuccess" = "Configuração importada com êxito"; /* Private key warning */ -"configImport.privateKeyWarning" = "Isto irá substituir a identidade criptográfica do nodo"; +"configImport.privateKeyWarning" = "Isto irá substituir a identidade criptográfica do nó"; "configImport.current" = "Atual: %@"; "configImport.new" = "Novo: %@"; @@ -1135,7 +1135,7 @@ "configImport.stepPrivateKey" = "A importar a chave privada"; /* Import progress: setting the node name */ -"configImport.stepNodeName" = "A definir o nome do nodo"; +"configImport.stepNodeName" = "A definir o nome do nó"; /* Import progress: applying radio parameters */ "configImport.stepRadioParameters" = "A definir os parâmetros do rádio"; @@ -1153,13 +1153,13 @@ "configImport.field.frequency" = "Frequência"; /* Validation field label: radio bandwidth */ -"configImport.field.bandwidth" = "Largura de banda"; +"configImport.field.bandwidth" = "Largura de banda (BW)"; /* Validation field label: spreading factor */ -"configImport.field.spreadingFactor" = "Fator de espalhamento"; +"configImport.field.spreadingFactor" = "Fator de espalhamento (SF)"; /* Validation field label: coding rate */ -"configImport.field.codingRate" = "Taxa de codificação"; +"configImport.field.codingRate" = "Taxa de codificação (CR)"; /* Validation field label: TX power */ "configImport.field.txPower" = "Potência de TX"; @@ -1180,7 +1180,7 @@ "configImport.error.contactCoordinateInvalid" = "O contacto \"%@\" %@ tem uma coordenada inválida ou fora do intervalo"; /* Validation error: contact has an invalid routing path (param: contact name) */ -"configImport.error.invalidOutPath" = "O contacto \"%@\" tem um caminho de routing inválido"; +"configImport.error.invalidOutPath" = "O contacto \"%@\" tem uma rota inválida"; /* Validation error: not enough free contact slots (params: needed count, available count) */ "configImport.error.contactCapacityExceeded" = "A importação precisa de %d slot(s) de contacto livre(s), mas restam apenas %d no dispositivo"; @@ -1192,7 +1192,7 @@ "configImport.error.invalidContactPublicKey" = "O contacto \"%@\" tem uma chave pública inválida"; /* Validation error: unsupported path hash mode (params: contact name, mode value) */ -"configImport.error.invalidPathHashMode" = "O contacto \"%1$@\" tem um modo de hash do caminho não suportado %2$lld (esperado 0, 1 ou 2)"; +"configImport.error.invalidPathHashMode" = "O contacto \"%1$@\" tem um modo de hash da rota inválido %2$lld (esperado 0, 1 ou 2)"; /* Validation error: private key wrong length (params: hex char count, expected hex char count) */ "configImport.error.invalidPrivateKey" = "Chave privada inválida (%1$lld caracteres hex, esperados %2$lld)"; @@ -1215,7 +1215,7 @@ "blocking.channelSenders.title" = "Remetentes de canal bloqueados"; /* Location: BlockedChannelSendersView.swift - Purpose: Empty state title */ -"blocking.channelSenders.empty.title" = "Nenhum utilizador bloqueado"; +"blocking.channelSenders.empty.title" = "Nenhum remetente bloqueado"; /* Location: BlockedChannelSendersView.swift - Purpose: Empty state description */ "blocking.channelSenders.empty.description" = "Os nomes de remetentes de canal que bloquear aparecem aqui."; @@ -1251,7 +1251,7 @@ "liveActivity.tip.title" = "Estado do rádio num relance"; /* Message for the Live Activity tip */ -"liveActivity.tip.message" = "A ligação, a bateria e as mensagens ficam visíveis — mesmo sem abrir a app."; +"liveActivity.tip.message" = "A ligação, a bateria e as mensagens ficam visíveis, mesmo sem abrir a app."; // MARK: - Regenerate Identity @@ -1427,7 +1427,7 @@ "offlineMaps.downloadHint" = "Introduza um nome e selecione uma área para transferir."; /* Download exceeds available storage */ -"offlineMaps.exceedsStorage" = "Não há armazenamento suficiente neste dispositivo. Aproxime o zoom para selecionar uma área menor."; +"offlineMaps.exceedsStorage" = "Não há armazenamento suficiente neste dispositivo. Aumente o zoom para selecionar uma área menor."; /* Layer type labels */ "offlineMaps.layer.base" = "Mapa base"; @@ -1452,7 +1452,7 @@ "settings.backup.file_backup.header" = "Backup em ficheiro"; /* Section footer for file backup */ -"settings.backup.file_backup.footer" = "Exporte ou restaure mensagens, contactos, canais, caminhos guardados e definições. A configuração do rádio é lida do dispositivo em cada ligação."; +"settings.backup.file_backup.footer" = "Exporte ou restaure mensagens, contactos, canais, rotas guardadas e definições. A configuração do rádio é lida do dispositivo em cada ligação."; /* Export row title */ "settings.backup.export.title" = "Exportar dados da app"; @@ -1473,7 +1473,7 @@ "settings.backup.export.alert.title" = "Aviso de segurança"; /* Export confirmation alert message */ -"settings.backup.export.alert.message" = "Este backup inclui as chaves de encriptação dos canais, o histórico de mensagens, a lista de contactos e o endereço de rede local e a porta do nodo Wi-Fi. Guarde o ficheiro exportado de forma segura."; +"settings.backup.export.alert.message" = "Este backup inclui as chaves de encriptação dos canais, o histórico de mensagens, a lista de contactos, o endereço de rede local e a porta do nó Wi-Fi. Guarde o ficheiro exportado de forma segura."; /* Export confirmation alert export button */ "settings.backup.export.alert.export" = "Exportar"; @@ -1518,10 +1518,10 @@ "settings.backup.import.preview.reactions" = "Reações"; /* Import preview manifest label: saved paths */ -"settings.backup.import.preview.saved_paths" = "Caminhos guardados"; +"settings.backup.import.preview.saved_paths" = "Rotas guardadas"; /* Import preview manifest label: remote node sessions */ -"settings.backup.import.preview.remote_node_sessions" = "Sessões de nodos remotos"; +"settings.backup.import.preview.remote_node_sessions" = "Sessões de nós remotos"; /* Import preview info text */ "settings.backup.import.preview.info" = "Apenas os dados novos são adicionados; os registos existentes não são substituídos. Os contactos eliminados depois de este backup ter sido criado voltam a aparecer, e quaisquer estados de bloqueio, silenciado ou favorito do backup são reaplicados."; @@ -1572,13 +1572,13 @@ "settings.backup.import.success.dropped_footer" = "Estes canais não tinham slot livre neste rádio, por isso eles e as respetivas mensagens não foram importados. Liberte um slot de canal e importe novamente para os restaurar."; /* Import success footer when only discovered nodes exceeded the per-radio discover-list cap */ -"settings.backup.import.success.dropped_footer_discovered_nodes" = "Estes nodos descobertos não puderam ser restaurados porque a lista de descoberta deste rádio está cheia (%d nodos). Os nodos locais existentes foram mantidos; a mesh voltará a emitir Adverts dos que faltam."; +"settings.backup.import.success.dropped_footer_discovered_nodes" = "Estes nós descobertos não puderam ser restaurados porque a lista de descoberta deste rádio está cheia (%d nós). Os nós locais existentes foram mantidos; a mesh voltará a emitir Adverts dos nós que faltam."; /* Import success footer when both channel slots and the discover-list cap caused drops */ -"settings.backup.import.success.dropped_footer_mixed" = "Alguns itens não puderam ser restaurados: canais sem slot livre no rádio e nodos descobertos que excederam a capacidade da lista de descoberta deste rádio (%d nodos). Liberte um slot de canal e importe novamente os canais; a mesh voltará a emitir Adverts dos nodos descobertos em falta."; +"settings.backup.import.success.dropped_footer_mixed" = "Alguns itens não puderam ser restaurados: canais sem slot livre no rádio e nós descobertos que excederam a capacidade da lista de descoberta deste rádio (%d nós). Liberte um slot de canal e importe novamente os canais; a mesh voltará a emitir Adverts dos nós descobertos em falta."; /* Import success done button */ -"settings.backup.import.success.done" = "OK"; +"settings.backup.import.success.done" = "Concluído"; /* Import failure title */ "settings.backup.import.error.title" = "Falha na importação"; @@ -1597,13 +1597,13 @@ "settings.backup.import.preview.blocked_senders" = "Remetentes bloqueados"; /* Import preview manifest label: node status snapshots */ -"settings.backup.import.preview.node_status_snapshots" = "Instantâneos de nodos"; +"settings.backup.import.preview.node_status_snapshots" = "Instantâneos de nós"; /* Import preview manifest label: discovered nodes */ -"settings.backup.import.preview.discovered_nodes" = "Nodos descobertos"; +"settings.backup.import.preview.discovered_nodes" = "Nós descobertos"; /* Import preview manifest label: message repeats */ -"settings.backup.import.preview.message_repeats" = "Repetições de mensagens"; +"settings.backup.import.preview.message_repeats" = "Retransmissões de mensagens"; /* Import header title when the backup had nothing new to add (every record was skipped) */ "settings.backup.import.nothing_to_import.title" = "Já está tudo aqui"; @@ -1644,7 +1644,7 @@ "settings.backup.export.success.included_section" = "Incluído no backup"; /* Primary button on the export success sheet */ -"settings.backup.export.success.done" = "OK"; +"settings.backup.export.success.done" = "Concluído"; /* VoiceOver announcement when the export success sheet appears; %@ is the filename */ "settings.backup.export.success.announcement" = "Backup guardado como %@"; @@ -1705,10 +1705,10 @@ "Support.Accessibility.ThemeCard.LockedLabel" = "Tema %@, bloqueado"; "Support.Accessibility.ThemeCard.OwnedLabel" = "Tema %@, adquirido"; "Support.Accessibility.BundleCard.LockedLabel" = "Pacote Todos os temas, bloqueado, %@"; -"Support.Accessibility.BundleCard.LockedHint" = "Compra o pacote Todos os temas"; +"Support.Accessibility.BundleCard.LockedHint" = "Compre o pacote Todos os temas"; "Support.Accessibility.BundleCard.LoadingLabel" = "Pacote Todos os temas, a carregar o preço"; "Support.Accessibility.ContributionRow.Label" = "%1$@, %2$@"; -"Support.Accessibility.ContributionRow.Hint" = "Envia um donativo de agradecimento"; +"Support.Accessibility.ContributionRow.Hint" = "Envie um donativo de agradecimento"; "Support.Accessibility.TipConfirmAnnouncement" = "Obrigado pelo donativo."; /* MARK: Appearance (selection) screen */ diff --git a/MC1/Resources/Localization/pt.lproj/Settings.stringsdict b/MC1/Resources/Localization/pt.lproj/Settings.stringsdict index 247fac506..ac447a569 100644 --- a/MC1/Resources/Localization/pt.lproj/Settings.stringsdict +++ b/MC1/Resources/Localization/pt.lproj/Settings.stringsdict @@ -21,9 +21,9 @@ NSStringFormatValueTypeKey d one - Isto irá eliminar permanentemente %d nodo não marcado como favorito do dispositivo e da app, juntamente com as respetivas mensagens. + Isto irá eliminar permanentemente do dispositivo e da app %d nó não marcado como favorito, juntamente com as respetivas mensagens. other - Isto irá eliminar permanentemente %d nodos não marcados como favoritos do dispositivo e da app, juntamente com as respetivas mensagens. + Isto irá eliminar permanentemente do dispositivo e da app %d nós não marcados como favoritos, juntamente com as respetivas mensagens. dangerZone.alert.removeUnfavorited.partial @@ -37,9 +37,9 @@ NSStringFormatValueTypeKey d one - Removido %1$d de %2$d nodo. A ligação foi interrompida — toque novamente no botão para tentar de novo. + Removido %1$d de %2$d nó. A ligação foi interrompida. Toque novamente no botão para tentar de novo. other - Removidos %1$d de %2$d nodos. A ligação foi interrompida — toque novamente no botão para tentar remover os nodos restantes. + Removidos %1$d de %2$d nós. A ligação foi interrompida. Toque novamente no botão para tentar remover os nós restantes. settings.backup.import.success.subtitle_added diff --git a/MC1/Resources/Localization/pt.lproj/Tools.strings b/MC1/Resources/Localization/pt.lproj/Tools.strings index ebb921190..dccb5f040 100644 --- a/MC1/Resources/Localization/pt.lproj/Tools.strings +++ b/MC1/Resources/Localization/pt.lproj/Tools.strings @@ -12,19 +12,19 @@ "tools.title" = "Ferramentas"; /* Location: ToolsView.swift - Tool selection label */ -"tools.tracePath" = "Traçar caminho"; +"tools.tracePath" = "Traçar rota"; /* Location: ToolsView.swift - Tool selection label */ "tools.lineOfSight" = "Linha de vista"; /* Location: ToolsView.swift - Tool selection label */ -"tools.rxLog" = "Registo RX"; +"tools.rxLog" = "Log RX"; /* Location: ToolsView.swift - Tool selection label */ "tools.noiseFloor" = "Ruído de fundo"; /* Location: ToolsView.swift - Tool selection label */ -"tools.nodeDiscovery" = "Descobrir nodos"; +"tools.nodeDiscovery" = "Descobrir nós"; /* Location: ToolsView.swift - Empty state when no tool selected */ "tools.selectTool" = "Selecione uma ferramenta"; @@ -38,7 +38,7 @@ "tools.rxLog.listeningDescription" = "Os pacotes RF aparecem aqui à medida que chegam."; /* Location: RxLogView.swift - Disconnected state title */ -"tools.rxLog.notConnected" = "Não ligado"; +"tools.rxLog.notConnected" = "Sem ligação"; /* Location: RxLogView.swift - Disconnected state description */ "tools.rxLog.notConnectedDescription" = "Ligue o rádio mesh para ver os pacotes RF."; @@ -86,7 +86,7 @@ "tools.rxLog.hopPlural" = "saltos"; /* Location: RxLogView.swift - Signal strength accessibility label, %@ is quality */ -"tools.rxLog.signalStrength" = "Intensidade do sinal: %@"; +"tools.rxLog.signalStrength" = "Sinal: %@"; /* Location: RxLogView.swift - Duplicate count accessibility label, %lld is count */ "tools.rxLog.receivedTimes" = "Recebido %lld vezes"; @@ -110,7 +110,7 @@ "tools.rxLog.sizeLabel" = "Tamanho:"; /* Location: RxLogView.swift - Path label */ -"tools.rxLog.pathLabel" = "Caminho:"; +"tools.rxLog.pathLabel" = "Rota:"; /* Location: RxLogView.swift - Hash label */ "tools.rxLog.hashLabel" = "Hash:"; @@ -145,7 +145,7 @@ "tools.rxLog.filter.all" = "Todos"; /* Location: RxLogViewModel.swift - Route filter: flood only */ -"tools.rxLog.filter.floodOnly" = "Só Flood"; +"tools.rxLog.filter.floodOnly" = "Só difusão"; /* Location: RxLogViewModel.swift - Route filter: direct only */ "tools.rxLog.filter.directOnly" = "Só direto"; @@ -157,7 +157,7 @@ "tools.rxLog.filter.failed" = "Falhou"; /* Location: DecryptStatus+Display.swift - Decrypt status: not a decryptable packet */ -"tools.rxLog.decryptStatus.notApplicable" = "N/A"; +"tools.rxLog.decryptStatus.notApplicable" = "N/D"; /* Location: DecryptStatus+Display.swift - Decrypt status: no stored channel key matches */ "tools.rxLog.decryptStatus.noKey" = "Sem chave"; @@ -175,7 +175,7 @@ "tools.rxLog.decryptStatus.hasKey" = "Com chave"; /* Location: DecryptStatus+Display.swift - Decrypt status: missing direct message key */ -"tools.rxLog.decryptStatus.noDmKey" = "Sem chave DM"; +"tools.rxLog.decryptStatus.noDmKey" = "Sem chave de contacto"; /* Location: RxLogRowView - Label for local device in path display */ "tools.rxLog.pathYou" = "Eu"; @@ -274,7 +274,7 @@ "tools.nodeDiscovery.noResults" = "Sem resultados para %@"; /* Location: NodeDiscoveryView.swift - No results empty state description */ -"tools.nodeDiscovery.noResultsDescription" = "%@ não responderam ao ping de descoberta. Tente novamente ou aproxime-se da mesh."; +"tools.nodeDiscovery.noResultsDescription" = "Nenhum dos %@ respondeu ao ping de descoberta. Tente novamente ou aproxime-se de um nó da mesh."; /* Location: NodeDiscoveryView.swift - Scan button label (idle) */ "tools.nodeDiscovery.scanButton" = "Procurar %@"; @@ -295,10 +295,10 @@ "tools.nodeDiscovery.sortName" = "Nome"; /* Location: NodeDiscoveryView.swift - Sort menu accessibility label */ -"tools.nodeDiscovery.sortMenu" = "Ordenar nodos"; +"tools.nodeDiscovery.sortMenu" = "Ordenar nós"; /* Location: NodeDiscoveryView.swift - Sort menu accessibility hint */ -"tools.nodeDiscovery.sortMenuHint" = "Altera a ordem dos nodos descobertos"; +"tools.nodeDiscovery.sortMenuHint" = "Altera a ordem dos nós descobertos"; /* Location: NodeDiscoveryRowView.swift - Unknown node name fallback */ "tools.nodeDiscovery.unknownNode" = "Desconhecido"; @@ -356,7 +356,7 @@ "tools.lineOfSight.loadingElevation" = "A carregar altitude..."; /* Location: LineOfSightView.swift - Long press points hint */ -"tools.lineOfSight.longPressPointsHint" = "Toque e mantenha premido no mapa para adicionar um local"; +"tools.lineOfSight.longPressPointsHint" = "Toque sem soltar no mapa para adicionar um local"; /* Location: LineOfSightView.swift - Elevation unavailable warning */ "tools.lineOfSight.elevationUnavailable" = "Dados de altitude indisponíveis. A utilizar o nível do mar (0 m) como aproximação."; @@ -377,7 +377,7 @@ "tools.lineOfSight.relocate" = "Reposicionar"; /* Location: LineOfSightView.swift - Done button */ -"tools.lineOfSight.done" = "OK"; +"tools.lineOfSight.done" = "Concluído"; /* Location: LineOfSightView.swift - Edit button */ "tools.lineOfSight.edit" = "Editar"; @@ -428,10 +428,10 @@ "tools.lineOfSight.refraction.standard" = "Padrão (k=1,33)"; /* Location: LineOfSightView.swift - Refraction: ducting */ -"tools.lineOfSight.refraction.ducting" = "Ducting (k=4)"; +"tools.lineOfSight.refraction.ducting" = "Conduta (k=4)"; /* Location: LineOfSightView.swift - Analyzing progress */ -"tools.lineOfSight.analyzing" = "A analisar o caminho..."; +"tools.lineOfSight.analyzing" = "A analisar o percurso..."; /* Location: LineOfSightView.swift - Analysis failed title */ "tools.lineOfSight.analysisFailed" = "A análise falhou"; @@ -443,7 +443,7 @@ "tools.lineOfSight.repeaterLocation" = "Localização do repetidor"; /* Location: LineOfSightViewModel.swift - Dropped pin display name */ -"tools.lineOfSight.droppedPin" = "Alfinete largado"; +"tools.lineOfSight.droppedPin" = "Ponto no mapa"; /* Location: LOSRepeaterPinView.swift - Accessibility hint for repeater pins */ /* Location: LOSPointPinView.swift - Accessibility hint for point pins */ @@ -483,7 +483,7 @@ "tools.lineOfSight.loss" = "perda"; /* Location: ResultsCardView.swift - Path loss breakdown section */ -"tools.lineOfSight.pathLossBreakdown" = "Detalhe da perda de caminho"; +"tools.lineOfSight.pathLossBreakdown" = "Detalhe da perda de percurso"; /* Location: ResultsCardView.swift - Free space loss label */ "tools.lineOfSight.freeSpaceLoss" = "Perda em espaço livre"; @@ -519,7 +519,7 @@ "tools.lineOfSight.status.clear" = "Livre"; /* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: marginal */ -"tools.lineOfSight.status.marginal" = "Marginal"; +"tools.lineOfSight.status.marginal" = "No limite"; /* Location: ClearanceStatusView.swift, ResultsCardView.swift - Clearance status: partial obstruction */ "tools.lineOfSight.status.partialObstruction" = "Obstrução parcial"; @@ -528,7 +528,7 @@ "tools.lineOfSight.status.blocked" = "Bloqueado"; /* Location: ResultsCardView.swift - Subtitle shown when path is blocked */ -"tools.lineOfSight.status.blockedSubtitle" = "O caminho direto intersecta o terreno"; +"tools.lineOfSight.status.blockedSubtitle" = "O percurso direto intersecta o terreno"; // MARK: - CLI Tool View @@ -536,7 +536,7 @@ "tools.cli" = "CLI"; /* Location: CLIToolView.swift - Disconnected state title */ -"tools.cli.notConnected" = "Não ligado"; +"tools.cli.notConnected" = "Sem ligação"; /* Location: CLIToolView.swift - Disconnected state description */ "tools.cli.notConnectedDescription" = "Ligue o rádio mesh para utilizar a CLI."; @@ -572,7 +572,7 @@ "tools.cli.loginFailedAuth" = "Falha na autenticação"; /* Location: CLIToolView.swift - Node not found error */ -"tools.cli.nodeNotFound" = "Nodo não encontrado:"; +"tools.cli.nodeNotFound" = "Nó não encontrado:"; /* Location: CLIToolView.swift - Password required error */ "tools.cli.passwordRequired" = "Palavra-passe necessária"; @@ -681,13 +681,13 @@ "tools.cli.helpRepeaterList4" = " setperm, tempradio, neighbor.remove"; /* Location: CLIToolViewModel+Sessions.swift - Nodes header with count */ -"tools.cli.nodesHeader" = "Nodos (%lld):"; +"tools.cli.nodesHeader" = "Nós (%lld):"; /* Location: CLIToolViewModel+Sessions.swift - Channels header with count */ "tools.cli.channelsHeader" = "Canais (%lld):"; /* Location: CLIToolViewModel+Sessions.swift - No nodes found */ -"tools.cli.noNodes" = "Nenhum nodo encontrado"; +"tools.cli.noNodes" = "Nenhum nó encontrado"; /* Location: CLIToolViewModel+Sessions.swift - No channels found */ "tools.cli.noChannels" = "Nenhum canal encontrado"; @@ -705,7 +705,7 @@ "tools.cli.welcomeHint" = "Introduza 'help' para ver os comandos disponíveis."; /* Location: CLIToolView.swift - Jump to bottom button */ -"tools.cli.jumpToBottom" = "Ir para o fundo"; +"tools.cli.jumpToBottom" = "Ir para o fim"; /* Location: CLIToolView.swift - Accessibility label for command input */ "tools.cli.commandInput" = "Introdução de comando"; @@ -763,13 +763,13 @@ // MARK: - Saved Paths /* Location: SavedPathsViewModel.swift - Error loading saved paths */ -"tools.savedPaths.loadFailed" = "Não foi possível carregar os caminhos guardados."; +"tools.savedPaths.loadFailed" = "Não foi possível carregar as rotas guardadas."; /* Location: SavedPathsViewModel.swift - Error renaming a saved path */ -"tools.savedPaths.renameFailed" = "Não foi possível mudar o nome do caminho."; +"tools.savedPaths.renameFailed" = "Não foi possível mudar o nome da rota."; /* Location: SavedPathsViewModel.swift - Error deleting a saved path */ -"tools.savedPaths.deleteFailed" = "Não foi possível eliminar o caminho."; +"tools.savedPaths.deleteFailed" = "Não foi possível eliminar a rota."; // MARK: - App Intents @@ -842,22 +842,22 @@ "intent.advert.shortTitle" = "Enviar Advert"; /* Location: SendAdvertIntent.swift - App Intents: description of the send advert intent */ -"intent.advert.description" = "Emite um Advert deste rádio para que os nodos da mesh próximos o possam descobrir."; +"intent.advert.description" = "Emite um Advert deste rádio para que os nós da mesh próximos o possam descobrir."; /* Location: SendAdvertIntent.swift - App Intents: title of the advert reach parameter (zero-hop or flood) */ -"intent.advert.param.reach" = "Âmbito"; +"intent.advert.param.reach" = "Alcance"; /* Location: AdvertReach.swift - App Intents: type name for the advert reach enum */ -"intent.advert.reach.type" = "Âmbito do Advert"; +"intent.advert.reach.type" = "Alcance do Advert"; /* Location: AdvertReach.swift - App Intents: zero-hop reach case (direct neighbors only) */ "intent.advert.reach.zeroHop" = "Zero-hop"; /* Location: AdvertReach.swift - App Intents: flood reach case (entire mesh) */ -"intent.advert.reach.flood" = "Flood"; +"intent.advert.reach.flood" = "Difusão"; /* Location: SendAdvertIntent.swift - Spoken after a zero-hop advert is sent */ -"intent.advert.dialog.sentZeroHop" = "Advert enviado para os nodos próximos."; +"intent.advert.dialog.sentZeroHop" = "Advert enviado para os nós próximos."; /* Location: SendAdvertIntent.swift - Spoken after a flood advert is sent */ "intent.advert.dialog.sentFlood" = "Advert enviado por toda a mesh."; diff --git a/MC1/Resources/Localization/pt.lproj/WhatsNew.strings b/MC1/Resources/Localization/pt.lproj/WhatsNew.strings index 451de4840..a512038ef 100644 --- a/MC1/Resources/Localization/pt.lproj/WhatsNew.strings +++ b/MC1/Resources/Localization/pt.lproj/WhatsNew.strings @@ -13,12 +13,12 @@ "whatsNew.continueButton" = "Continuar"; /* Link on the What's New sheet to the full GitHub release notes */ -"whatsNew.fullReleaseNotes" = "Notas de versão completas"; +"whatsNew.fullReleaseNotes" = "Ver as notas de versão completas"; /* What's New v1.3 - Improved chats feature, title */ -"whatsNew.fasterChats.title" = "Chats melhorados"; +"whatsNew.fasterChats.title" = "Conversas melhoradas"; /* What's New v1.3 - Improved chats feature, description */ -"whatsNew.fasterChats.description" = "Ao abrir um chat, o ecrã salta para as mensagens novas em vez do fim. A hora aparece em cada balão, e o histórico e as pré-visualizações de ligações carregam mais depressa."; +"whatsNew.fasterChats.description" = "Ao abrir uma conversa, o ecrã salta para as mensagens novas em vez de ir para o fim. A hora aparece em cada balão, e o histórico e as pré-visualizações de links carregam mais depressa."; /* What's New v1.3 - Contact photos feature, title */ "whatsNew.contactPhotos.title" = "Fotos de contactos"; @@ -28,4 +28,4 @@ /* What's New v1.3 - Map filters feature, title */ "whatsNew.mapFilters.title" = "Filtros do mapa"; /* What's New v1.3 - Map filters feature, description */ -"whatsNew.mapFilters.description" = "Filtre os alfinetes por favoritos, nodos descobertos e tipo de nodo."; +"whatsNew.mapFilters.description" = "Filtre os pontos por favoritos, nós descobertos e tipo de nó."; From 55a31928402f9963941e022c01945970a95ccca8 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:16:01 -0700 Subject: [PATCH 45/47] fix(l10n): match pt-PT default scope to #413 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark Portuguese as verified by zadoke #413 - Default-scope keys now use scope, nó, and Adverts --- MC1/Resources/Localization/pt.lproj/RemoteNodes.strings | 4 ++-- TRANSLATIONS.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings index 46a36cf23..791766898 100644 --- a/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings +++ b/MC1/Resources/Localization/pt.lproj/RemoteNodes.strings @@ -359,13 +359,13 @@ "remoteNodes.settings.regions.homeRegion" = "Região home"; /* Location: RepeaterSettingsView.swift - Default scope picker label */ -"remoteNodes.settings.regions.defaultScope" = "Âmbito predefinido"; +"remoteNodes.settings.regions.defaultScope" = "Scope predefinido"; /* Location: RepeaterSettingsView.swift - No default scope */ "remoteNodes.settings.regions.noDefault" = "Nenhum"; /* Location: RepeaterSettingsView.swift - Caption under default scope picker */ -"remoteNodes.settings.regions.defaultScopeCaption" = "Aplica o âmbito aos pacotes originados por este nodo, como os anúncios. Esta definição é guardada imediatamente no repetidor."; +"remoteNodes.settings.regions.defaultScopeCaption" = "Aplica o scope aos pacotes originados por este nó, como os Adverts. Esta definição é guardada imediatamente no repetidor."; /* Location: RepeaterSettingsView.swift - No home region set */ /* Location: RepeaterSettingsView.swift - Toggle label for flood allow per region */ diff --git a/TRANSLATIONS.md b/TRANSLATIONS.md index 97d38384e..4ab0558fa 100644 --- a/TRANSLATIONS.md +++ b/TRANSLATIONS.md @@ -12,7 +12,7 @@ MeshCore One supports multiple languages. You can help improve translations enti | German | de | AI-translated | | Italian | it | Verified by corradoignoti #376 | | Polish | pl | AI-translated | -| Portuguese (Portugal) | pt | AI-translated | +| Portuguese (Portugal) | pt | Verified by zadoke #413 | | Russian | ru | AI-translated | | Simplified Chinese | zh-Hans | Verified by MGJ520 #225 | | Spanish | es | AI-translated | From 8a9d84f3fab31fbcbe107507bb4d0294ad68b22e Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:46:58 -0700 Subject: [PATCH 46/47] fix(sim): keep contact avatars on reseed --- .../MC1Services/Simulator/SimulatorConnectionMode.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift index 802a99159..14fdf1834 100644 --- a/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift +++ b/MC1Services/Sources/MC1Services/Simulator/SimulatorConnectionMode.swift @@ -39,8 +39,13 @@ final class SimulatorConnectionMode { func seedDataStore(_ dataStore: PersistenceStore) async throws { try await dataStore.saveDevice(MockDataProvider.simulatorDevice) + // Mock contacts omit avatarImageData; copy any existing value onto the upsert so saveContact(_:) does not apply nil. for contact in MockDataProvider.contacts { - try await dataStore.saveContact(contact) + if let existingAvatar = try await dataStore.fetchContact(id: contact.id)?.avatarImageData { + try await dataStore.saveContact(contact.with(avatarImageData: existingAvatar)) + } else { + try await dataStore.saveContact(contact) + } } for channel in MockDataProvider.channels { From 24b8da33e13d5e028b28e9e74f8e2def780e30c3 Mon Sep 17 00:00:00 2001 From: Avi0n <14863961+Avi0n@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:59:03 -0700 Subject: [PATCH 47/47] test(ble): poll watchdog natural exit --- .../ConnectionManagerReconnectAbandonmentTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift index a477f8661..b8cadf185 100644 --- a/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift +++ b/MC1Services/Tests/MC1ServicesTests/ConnectionManagerReconnectAbandonmentTests.swift @@ -214,7 +214,11 @@ struct ConnectionManagerReconnectAbandonmentTests { // Guard-exit: intent no longer wants connection when the sleep completes. env.manager.setTestState(connectionIntent: .userDisconnected) - try await Task.sleep(for: .milliseconds(80)) + // After testWatchdogInitialDelay the watchdog Task resumes on MainActor. + // Wait until the natural-exit defer has niled reconnectionWatchdogTask. + try await waitUntil(timeout: .seconds(2), "watchdog should nil after natural exit") { + !env.manager.isReconnectionWatchdogRunning + } // Natural-exit defer must nil the finished Task (not isCancelled). #expect(!env.manager.isReconnectionWatchdogRunning)