diff --git a/MeshCore/Sources/MeshCore/Events/MeshEvent.swift b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift index b205e8b2..9515d04c 100644 --- a/MeshCore/Sources/MeshCore/Events/MeshEvent.swift +++ b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift @@ -108,7 +108,7 @@ public enum MeshEvent: Sendable { /// Indicates that auto-add configuration was received. /// /// Emitted in response to ``MeshCoreSession/getAutoAddConfig()``. - case autoAddConfig(UInt8) + case autoAddConfig(AutoAddConfig) /// Indicates that allowed repeat frequency ranges were received. /// @@ -1053,6 +1053,21 @@ public struct DiscoverResponse: Sendable, Equatable { } } +/// Auto-add configuration received from the device. +/// +/// Bundles the bitmask (which node types to auto-add) with the max hops filter. +public struct AutoAddConfig: Sendable, Equatable { + /// Bitmask controlling auto-add behavior (0x01=overwrite, 0x02=contacts, 0x04=repeaters, 0x08=rooms). + public let bitmask: UInt8 + /// Maximum hops for auto-add filtering. 0 = no limit, 1 = direct only, N = up to N-1 hops (max 64). + public let maxHops: UInt8 + + public init(bitmask: UInt8, maxHops: UInt8 = 0) { + self.bitmask = bitmask + self.maxHops = maxHops + } +} + /// Represents an allowed frequency range for client repeat mode. public struct FrequencyRange: Sendable, Equatable { /// The lower bound of the frequency range in kHz. diff --git a/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift b/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift index 77442a15..29485a2b 100644 --- a/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift +++ b/MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift @@ -643,9 +643,9 @@ public enum PacketBuilder: Sendable { } /// Builds a packet to set the auto-add configuration. - /// - Parameter config: The bitmask (0x01=overwrite, 0x02=contacts, 0x04=repeaters, 0x08=rooms) - public static func setAutoAddConfig(_ config: UInt8) -> Data { - Data([CommandCode.setAutoAddConfig.rawValue, config]) + /// - Parameter config: The auto-add configuration (bitmask + max hops). + public static func setAutoAddConfig(_ config: AutoAddConfig) -> Data { + Data([CommandCode.setAutoAddConfig.rawValue, config.bitmask, config.maxHops]) } /// Builds a getSelfTelemetry command to request current sensor data from the device. diff --git a/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift b/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift index cb271a12..c18b9ed4 100644 --- a/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift +++ b/MeshCore/Sources/MeshCore/Protocol/PacketParser.swift @@ -144,14 +144,14 @@ extension PacketParser { return Parsers.TuningParamsResponse.parse(payload) case .autoAddConfig: - // Single byte bitmask guard payload.count >= 1 else { return .parseFailure( data: payload, reason: "AutoAddConfig response too short: \(payload.count) < 1" ) } - return .autoAddConfig(payload[0]) + let maxHops: UInt8 = payload.count >= 2 ? payload[1] : 0 + return .autoAddConfig(AutoAddConfig(bitmask: payload[0], maxHops: maxHops)) case .allowedRepeatFreq: return Parsers.AllowedRepeatFreq.parse(payload) diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 1e69882e..20fda9b0 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -849,6 +849,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { private func performStatusRequest(from publicKey: Data) async throws -> StatusResponse { let data = PacketBuilder.binaryRequest(to: publicKey, type: .status) let publicKeyPrefix = Data(publicKey.prefix(6)) + let prefixHex = publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + let startTime = ContinuousClock.now + + logger.info("Status request to \(prefixHex): sending") // Subscribe BEFORE sending to avoid race condition where binaryResponse // arrives before we can register the pending request @@ -861,7 +865,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { return try await withThrowingTaskGroup(of: StatusResponse?.self) { group in let (timeoutStream, timeoutContinuation) = AsyncStream.makeStream() - group.addTask { + group.addTask { [logger] in var expectedAck: Data? for await event in events { @@ -872,7 +876,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { // Capture the expectedAck from firmware's MSG_SENT response expectedAck = info.expectedAck // Signal dynamic timeout to timeout task - let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2 + let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0 + logger.info("Status request to \(prefixHex): messageSent received, suggestedTimeoutMs=\(info.suggestedTimeoutMs), effective timeout=\(String(format: "%.1f", timeout))s") timeoutContinuation.yield(timeout) timeoutContinuation.finish() @@ -889,10 +894,14 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { ) else { return nil } + let elapsed = ContinuousClock.now - startTime + logger.info("Status request to \(prefixHex): response received in \(elapsed)") return response case .statusResponse(let response): // Handle already-routed response (if routing happens elsewhere) + let elapsed = ContinuousClock.now - startTime + logger.info("Status request to \(prefixHex): routed response received in \(elapsed)") return response default: @@ -903,14 +912,19 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { return nil } - group.addTask { [clock = self.clock, defaultTimeout = configuration.defaultTimeout] in + group.addTask { [logger, clock = self.clock, defaultTimeout = configuration.defaultTimeout] in // Wait for dynamic timeout from event task, or use default var timeout = defaultTimeout + var usedFirmwareTimeout = false for await t in timeoutStream { timeout = t + usedFirmwareTimeout = true break } + logger.info("Status request to \(prefixHex): timeout task sleeping for \(String(format: "%.1f", timeout))s (\(usedFirmwareTimeout ? "firmware" : "default"))") try await clock.sleep(for: .seconds(timeout)) + let elapsed = ContinuousClock.now - startTime + logger.warning("Status request to \(prefixHex): timed out after \(elapsed)") return nil } @@ -1162,10 +1176,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Gets the current auto-add configuration from the device. /// - /// - Returns: The auto-add config bitmask. + /// - Returns: The auto-add configuration (bitmask + max hops). /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. - public func getAutoAddConfig() async throws -> UInt8 { + public func getAutoAddConfig() async throws -> AutoAddConfig { try await sendAndWaitWithError(PacketBuilder.getAutoAddConfig()) { event in if case .autoAddConfig(let config) = event { return config } return nil @@ -1174,10 +1188,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Sets the auto-add configuration on the device. /// - /// - Parameter config: The bitmask (0x01=overwrite, 0x02=contacts, 0x04=repeaters, 0x08=rooms). + /// - Parameter config: The auto-add configuration (bitmask + max hops). /// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond. /// ``MeshCoreError/deviceError(code:)`` if the device returns an error. - public func setAutoAddConfig(_ config: UInt8) async throws { + public func setAutoAddConfig(_ config: AutoAddConfig) async throws { try await sendAndWaitWithError(PacketBuilder.setAutoAddConfig(config)) { event in if case .ok = event { return () } return nil @@ -1516,11 +1530,12 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// until `.noMoreMessages` is returned to drain the queue. /// Use ``startAutoMessageFetching()`` to automate this process. /// + /// - Parameter timeout: Optional timeout override in seconds. Uses `configuration.defaultTimeout` when `nil`. /// - Returns: A ``MessageResult`` containing either a contact message, channel message, /// or indication that no more messages are waiting. /// - Throws: ``MeshCoreError`` if the fetch fails. - public func getMessage() async throws -> MessageResult { - let timeoutSeconds = configuration.defaultTimeout + public func getMessage(timeout: TimeInterval? = nil) async throws -> MessageResult { + let timeoutSeconds = timeout ?? configuration.defaultTimeout let stream = await dispatcher.subscribe { event in switch event { @@ -1800,6 +1815,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { let telemetryPayload = Data([0x00, 0x00, 0x00, 0x00]) let data = PacketBuilder.binaryRequest(to: publicKey, type: .telemetry, payload: telemetryPayload) let publicKeyPrefix = Data(publicKey.prefix(6)) + let prefixHex = publicKeyPrefix.map { String(format: "%02x", $0) }.joined() + let startTime = ContinuousClock.now + + logger.info("Telemetry request to \(prefixHex): sending") // Subscribe BEFORE sending to avoid race condition where binaryResponse // arrives before we can register the pending request @@ -1812,7 +1831,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { return try await withThrowingTaskGroup(of: TelemetryResponse?.self) { group in let (timeoutStream, timeoutContinuation) = AsyncStream.makeStream() - group.addTask { + group.addTask { [logger] in var expectedAck: Data? for await event in events { @@ -1823,7 +1842,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { // Capture the expectedAck from firmware's MSG_SENT response expectedAck = info.expectedAck // Signal dynamic timeout to timeout task - let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2 + let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0 + logger.info("Telemetry request to \(prefixHex): messageSent received, suggestedTimeoutMs=\(info.suggestedTimeoutMs), effective timeout=\(String(format: "%.1f", timeout))s") timeoutContinuation.yield(timeout) timeoutContinuation.finish() @@ -1838,10 +1858,14 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { responseData, publicKeyPrefix: publicKeyPrefix ) + let elapsed = ContinuousClock.now - startTime + logger.info("Telemetry request to \(prefixHex): response received in \(elapsed)") return response case .telemetryResponse(let response): // Handle already-routed response (if routing happens elsewhere) + let elapsed = ContinuousClock.now - startTime + logger.info("Telemetry request to \(prefixHex): routed response received in \(elapsed)") return response default: @@ -1852,14 +1876,19 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { return nil } - group.addTask { [clock = self.clock, defaultTimeout = configuration.defaultTimeout] in + group.addTask { [logger, clock = self.clock, defaultTimeout = configuration.defaultTimeout] in // Wait for dynamic timeout from event task, or use default var timeout = defaultTimeout + var usedFirmwareTimeout = false for await t in timeoutStream { timeout = t + usedFirmwareTimeout = true break } + logger.info("Telemetry request to \(prefixHex): timeout task sleeping for \(String(format: "%.1f", timeout))s (\(usedFirmwareTimeout ? "firmware" : "default"))") try await clock.sleep(for: .seconds(timeout)) + let elapsed = ContinuousClock.now - startTime + logger.warning("Telemetry request to \(prefixHex): timed out after \(elapsed)") return nil } @@ -1928,7 +1957,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { case .messageSent(let info): expectedAck = info.expectedAck // Signal dynamic timeout to timeout task - let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2 + let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0 timeoutContinuation.yield(timeout) timeoutContinuation.finish() @@ -2004,7 +2033,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { case .messageSent(let info): expectedAck = info.expectedAck // Signal dynamic timeout to timeout task - let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2 + let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0 timeoutContinuation.yield(timeout) timeoutContinuation.finish() @@ -2112,7 +2141,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { case .messageSent(let info): expectedAck = info.expectedAck // Signal dynamic timeout to timeout task - let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2 + let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0 timeoutContinuation.yield(timeout) timeoutContinuation.finish() diff --git a/MeshCore/Tests/MeshCoreTests/Protocol/V112ProtocolTests.swift b/MeshCore/Tests/MeshCoreTests/Protocol/V112ProtocolTests.swift index dc969934..73ffdca5 100644 --- a/MeshCore/Tests/MeshCoreTests/Protocol/V112ProtocolTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Protocol/V112ProtocolTests.swift @@ -194,23 +194,30 @@ struct V112ProtocolTests { @Test("setAutoAddConfig packet builder") func setAutoAddConfigPacketBuilder() { - let packet = PacketBuilder.setAutoAddConfig(0x0F) + let packet = PacketBuilder.setAutoAddConfig(AutoAddConfig(bitmask: 0x0F)) - #expect(packet == Data([0x3A, 0x0F])) + #expect(packet == Data([0x3A, 0x0F, 0x00])) } @Test("setAutoAddConfig all bits set") func setAutoAddConfigAllBitsSet() { - let packet = PacketBuilder.setAutoAddConfig(0xFF) + let packet = PacketBuilder.setAutoAddConfig(AutoAddConfig(bitmask: 0xFF)) - #expect(packet == Data([0x3A, 0xFF])) + #expect(packet == Data([0x3A, 0xFF, 0x00])) } @Test("setAutoAddConfig zero bits") func setAutoAddConfigZeroBits() { - let packet = PacketBuilder.setAutoAddConfig(0x00) + let packet = PacketBuilder.setAutoAddConfig(AutoAddConfig(bitmask: 0x00)) - #expect(packet == Data([0x3A, 0x00])) + #expect(packet == Data([0x3A, 0x00, 0x00])) + } + + @Test("setAutoAddConfig with maxHops") + func setAutoAddConfigWithMaxHops() { + let packet = PacketBuilder.setAutoAddConfig(AutoAddConfig(bitmask: 0x0F, maxHops: 3)) + + #expect(packet == Data([0x3A, 0x0F, 0x03])) } // MARK: - Auto-Add Config Response Parser Tests @@ -227,14 +234,29 @@ struct V112ProtocolTests { #expect(ResponseCode.autoAddConfig.category == .device) } - @Test("autoAddConfig parses valid payload") - func autoAddConfigParsesValidPayload() { + @Test("autoAddConfig parses single-byte payload with default maxHops") + func autoAddConfigParsesSingleBytePayload() { let packet = Data([0x19, 0x0F]) let event = PacketParser.parse(packet) if case .autoAddConfig(let config) = event { - #expect(config == 0x0F) + #expect(config.bitmask == 0x0F) + #expect(config.maxHops == 0) + } else { + Issue.record("Expected .autoAddConfig event, got \(event)") + } + } + + @Test("autoAddConfig parses two-byte payload with maxHops") + func autoAddConfigParsesTwoBytePayload() { + let packet = Data([0x19, 0x0F, 0x05]) + + let event = PacketParser.parse(packet) + + if case .autoAddConfig(let config) = event { + #expect(config.bitmask == 0x0F) + #expect(config.maxHops == 5) } else { Issue.record("Expected .autoAddConfig event, got \(event)") } @@ -253,14 +275,15 @@ struct V112ProtocolTests { } } - @Test("autoAddConfig ignores extra bytes") + @Test("autoAddConfig ignores extra bytes beyond maxHops") func autoAddConfigIgnoresExtraBytes() { - let packet = Data([0x19, 0x0E, 0xFF, 0xFF]) + let packet = Data([0x19, 0x0E, 0x03, 0xFF, 0xFF]) let event = PacketParser.parse(packet) if case .autoAddConfig(let config) = event { - #expect(config == 0x0E) + #expect(config.bitmask == 0x0E) + #expect(config.maxHops == 3) } else { Issue.record("Expected .autoAddConfig event, got \(event)") } diff --git a/MeshCore/Tests/MeshCoreTests/Session/GetMessageTimeoutTests.swift b/MeshCore/Tests/MeshCoreTests/Session/GetMessageTimeoutTests.swift index 23aa96d9..a38b373b 100644 --- a/MeshCore/Tests/MeshCoreTests/Session/GetMessageTimeoutTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Session/GetMessageTimeoutTests.swift @@ -15,4 +15,40 @@ struct GetMessageTimeoutTests { _ = try await session.getMessage() } } + + @Test("getMessage timeout override can be shorter than the session default") + func getMessageRespectsShortTimeoutOverride() async { + let transport = MockTransport() + try? await transport.connect() + + let configuration = SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MeshCore-Tests") + let session = MeshCoreSession(transport: transport, configuration: configuration) + let clock = ContinuousClock() + let start = clock.now + + await #expect(throws: MeshCoreError.self) { + _ = try await session.getMessage(timeout: 0.02) + } + + let elapsed = start.duration(to: clock.now) + #expect(elapsed < .milliseconds(100)) + } + + @Test("getMessage timeout override can extend beyond the session default") + func getMessageRespectsLongTimeoutOverride() async { + let transport = MockTransport() + try? await transport.connect() + + let configuration = SessionConfiguration(defaultTimeout: 0.02, clientIdentifier: "MeshCore-Tests") + let session = MeshCoreSession(transport: transport, configuration: configuration) + let clock = ContinuousClock() + let start = clock.now + + await #expect(throws: MeshCoreError.self) { + _ = try await session.getMessage(timeout: 0.12) + } + + let elapsed = start.duration(to: clock.now) + #expect(elapsed >= .milliseconds(80)) + } } diff --git a/PocketMesh/Resources/Generated/L10n.swift b/PocketMesh/Resources/Generated/L10n.swift index 6ac730a2..8d8f30ca 100644 --- a/PocketMesh/Resources/Generated/L10n.swift +++ b/PocketMesh/Resources/Generated/L10n.swift @@ -127,6 +127,10 @@ public enum L10n { /// Location: ChannelInfoSheet.swift - Confirmation dialog title public static let title = L10n.tr("Chats", "chats.channelInfo.deleteConfirm.title", fallback: "Delete Channel") } + public enum DeleteFailed { + /// Location: ChatsView.swift - Alert title when channel deletion fails + public static let title = L10n.tr("Chats", "chats.channelInfo.deleteFailed.title", fallback: "Channel Deletion Failed") + } } public enum ChannelOptions { /// Location: ChannelOptionsSheet.swift - Loading indicator text @@ -315,6 +319,10 @@ public enum L10n { public static func characterCount(_ p1: Int, _ p2: Int) -> String { return L10n.tr("Chats", "chats.input.characterCount", p1, p2, fallback: "%d of %d characters") } + /// Location: ChatInputBar.swift - Accessibility label for encrypted indicator + public static let encrypted = L10n.tr("Chats", "chats.input.encrypted", fallback: "Encrypted") + /// Location: ChatInputBar.swift - Accessibility label for not encrypted indicator + public static let notEncrypted = L10n.tr("Chats", "chats.input.notEncrypted", fallback: "Not encrypted") /// Location: ChatInputBar.swift - Accessibility hint when over character limit - %d is characters to remove public static func removeCharacters(_ p1: Int) -> String { return L10n.tr("Chats", "chats.input.removeCharacters", p1, fallback: "Remove %d characters to send") @@ -331,7 +339,7 @@ public enum L10n { public static let typeFirst = L10n.tr("Chats", "chats.input.typeFirst", fallback: "Type a message first") public enum Placeholder { /// Location: ChatView.swift - Input bar placeholder for direct messages - public static let directMessage = L10n.tr("Chats", "chats.input.placeholder.directMessage", fallback: "Private Message") + public static let directMessage = L10n.tr("Chats", "chats.input.placeholder.directMessage", fallback: "Direct Message") } } public enum JoinFromMessage { @@ -1661,7 +1669,7 @@ public enum L10n { /// Location: TracePathListView.swift - Purpose: Code input footer public static let codeFooter = L10n.tr("Contacts", "contacts.trace.list.codeFooter", fallback: "Press Return to add repeaters") /// Location: TracePathListView.swift - Purpose: Code input placeholder - public static let codePlaceholder = L10n.tr("Contacts", "contacts.trace.list.codePlaceholder", fallback: "Example: A1, 2B, 9S") + public static let codePlaceholder = L10n.tr("Contacts", "contacts.trace.list.codePlaceholder", fallback: "Example: A1, 2B") /// Location: TracePathListView.swift - Purpose: Copy path button public static let copyPath = L10n.tr("Contacts", "contacts.trace.list.copyPath", fallback: "Copy Path") /// Location: TracePathListView.swift - Purpose: Empty path instruction @@ -2072,6 +2080,8 @@ public enum L10n { public static let continueDemo = L10n.tr("Onboarding", "deviceScan.continueDemo", fallback: "Continue in Demo Mode") /// Location: DeviceScanView.swift - Button for troubleshooting public static let deviceNotAppearing = L10n.tr("Onboarding", "deviceScan.deviceNotAppearing", fallback: "Device not appearing?") + /// Location: DeviceScanView.swift - Button to retry connection after other-app conflict + public static let retryConnection = L10n.tr("Onboarding", "deviceScan.retryConnection", fallback: "Retry Connection") /// Location: DeviceScanView.swift - Subtitle with pairing instructions public static let subtitle = L10n.tr("Onboarding", "deviceScan.subtitle", fallback: "Make sure your MeshCore device is powered on and nearby") /// Location: DeviceScanView.swift - Screen title for device pairing @@ -3356,6 +3366,14 @@ public enum L10n { public static let notSharing = L10n.tr("Settings", "location.notSharing", fallback: "Not sharing") /// Detail text when location is being shared publicly public static let sharingPublicly = L10n.tr("Settings", "location.sharingPublicly", fallback: "Sharing publicly") + public enum DeviceGps { + /// Footer for device GPS controls + public static let footer = L10n.tr("Settings", "location.deviceGps.footer", fallback: "Turns the radio's built-in GPS on or off. Saving a manual map location turns Device GPS off.") + /// Section header for device GPS controls + public static let header = L10n.tr("Settings", "location.deviceGps.header", fallback: "Device GPS") + /// Toggle label for device GPS power + public static let toggle = L10n.tr("Settings", "location.deviceGps.toggle", fallback: "Enable Device GPS") + } public enum GpsSource { /// GPS source option: device GPS public static let device = L10n.tr("Settings", "location.gpsSource.device", fallback: "Device GPS") @@ -3428,6 +3446,10 @@ public enum L10n { public static let autoAddRoomServers = L10n.tr("Settings", "nodes.autoAddRoomServers", fallback: "Room Servers") /// Section header for nodes settings public static let header = L10n.tr("Settings", "nodes.header", fallback: "Nodes") + /// Picker label for max hop distance + public static let maxHops = L10n.tr("Settings", "nodes.maxHops", fallback: "Max Hop Distance") + /// Description for max hop distance picker + public static let maxHopsDescription = L10n.tr("Settings", "nodes.maxHopsDescription", fallback: "Restrict which nodes are auto-added based on their hop count.") /// Toggle label for overwrite oldest public static let overwriteOldest = L10n.tr("Settings", "nodes.overwriteOldest", fallback: "Overwrite Oldest") /// Description for overwrite oldest toggle @@ -3450,6 +3472,20 @@ public enum L10n { /// Section header for auto-add types public static let header = L10n.tr("Settings", "nodes.autoAddTypes.header", fallback: "Auto-Add Types") } + public enum MaxHops { + /// Direct only option (0 hops) + public static let directOnly = L10n.tr("Settings", "nodes.maxHops.directOnly", fallback: "Direct Only") + /// Footer text when hop limit is active + public static let footerActive = L10n.tr("Settings", "nodes.maxHops.footerActive", fallback: "Nodes beyond the selected hop distance will not be auto-added.") + /// Plural hops format string + public static func hops(_ p1: Int) -> String { + return L10n.tr("Settings", "nodes.maxHops.hops", p1, fallback: "%d Hops") + } + /// No limit option + public static let noLimit = L10n.tr("Settings", "nodes.maxHops.noLimit", fallback: "No Limit") + /// Singular 1 hop option + public static let oneHop = L10n.tr("Settings", "nodes.maxHops.oneHop", fallback: "1 Hop") + } public enum StaleCleanup { /// Threshold option: number of days (%d = day count) public static func days(_ p1: Int) -> String { diff --git a/PocketMesh/Resources/Localization/de.lproj/Chats.strings b/PocketMesh/Resources/Localization/de.lproj/Chats.strings index 364dad3b..e9243fa5 100644 --- a/PocketMesh/Resources/Localization/de.lproj/Chats.strings +++ b/PocketMesh/Resources/Localization/de.lproj/Chats.strings @@ -155,7 +155,7 @@ "chats.contactInfo.hasLocation" = "Hat Standort"; /* Location: ChatView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "Private Nachricht"; +"chats.input.placeholder.directMessage" = "Direktnachricht"; // MARK: - Channel Chat View @@ -236,6 +236,9 @@ /* Location: ChannelInfoSheet.swift - Footer explaining delete action */ "chats.channelInfo.deleteFooter" = "Das Löschen entfernt diesen Kanal von deinem Gerät. Du kannst später erneut beitreten, wenn du den geheimen Schlüssel hast."; +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Kanal konnte nicht gelöscht werden"; + /* Location: ChannelInfoSheet.swift - Error when device not connected */ "chats.error.noDeviceConnected" = "Kein Gerät verbunden"; @@ -681,6 +684,8 @@ /* Location: ChatInputBar.swift - Accessibility hint when message is empty */ "chats.input.typeFirst" = "Schreibe zuerst eine Nachricht"; +"chats.input.encrypted" = "Verschlüsselt"; +"chats.input.notEncrypted" = "Nicht verschlüsselt"; // MARK: - Message Path Sheet diff --git a/PocketMesh/Resources/Localization/de.lproj/Contacts.strings b/PocketMesh/Resources/Localization/de.lproj/Contacts.strings index 3e4b581f..fb8aaf1f 100644 --- a/PocketMesh/Resources/Localization/de.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/de.lproj/Contacts.strings @@ -778,7 +778,7 @@ // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Code input placeholder */ -"contacts.trace.list.codePlaceholder" = "Beispiel: A1, 2B, 9S"; +"contacts.trace.list.codePlaceholder" = "Beispiel: A1, 2B"; /* Location: TracePathListView.swift - Purpose: Paste button */ "contacts.trace.list.paste" = "Aus Zwischenablage einfügen"; diff --git a/PocketMesh/Resources/Localization/de.lproj/Onboarding.strings b/PocketMesh/Resources/Localization/de.lproj/Onboarding.strings index bef974a3..93fa55ee 100644 --- a/PocketMesh/Resources/Localization/de.lproj/Onboarding.strings +++ b/PocketMesh/Resources/Localization/de.lproj/Onboarding.strings @@ -117,6 +117,9 @@ /* Location: DeviceScanView.swift - Button to add a new device */ "deviceScan.addDevice" = "Gerät hinzufügen"; +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Verbindung erneut versuchen"; + /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "Gerät erscheint nicht?"; diff --git a/PocketMesh/Resources/Localization/de.lproj/Settings.strings b/PocketMesh/Resources/Localization/de.lproj/Settings.strings index a5af1fa7..fe9c249c 100644 --- a/PocketMesh/Resources/Localization/de.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/de.lproj/Settings.strings @@ -504,6 +504,26 @@ /* Description for overwrite oldest toggle */ "nodes.overwriteOldestDescription" = "Bei vollem Speicher den ältesten nicht favorisierten Knoten ersetzen"; +/* Picker label for max hop distance */ +"nodes.maxHops" = "Max. Hop-Distanz"; +/* Description for max hop distance picker */ +"nodes.maxHopsDescription" = "Einschränken, welche Knoten basierend auf ihrer Hop-Anzahl automatisch hinzugefügt werden."; + +/* No limit option */ +"nodes.maxHops.noLimit" = "Kein Limit"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Nur direkt"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 Hop"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d Hops"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "Knoten jenseits der ausgewählten Hop-Distanz werden nicht automatisch hinzugefügt."; + // MARK: - Auto-Remove Old Nodes Section @@ -978,6 +998,15 @@ /* GPS source option: device GPS */ "location.gpsSource.device" = "Geräte-GPS"; +/* Section header for device GPS controls */ +"location.deviceGps.header" = "Geräte-GPS"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Geräte-GPS aktivieren"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Schaltet das eingebaute GPS des Funkgeräts ein oder aus. Das Speichern eines manuellen Kartenstandorts schaltet das Geräte-GPS aus."; + /* Detail text when location is being shared publicly */ "location.sharingPublicly" = "Öffentlich teilen"; diff --git a/PocketMesh/Resources/Localization/en.lproj/Chats.strings b/PocketMesh/Resources/Localization/en.lproj/Chats.strings index 0bc91769..f0192720 100644 --- a/PocketMesh/Resources/Localization/en.lproj/Chats.strings +++ b/PocketMesh/Resources/Localization/en.lproj/Chats.strings @@ -155,7 +155,7 @@ "chats.contactInfo.hasLocation" = "Has location"; /* Location: ChatView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "Private Message"; +"chats.input.placeholder.directMessage" = "Direct Message"; // MARK: - Channel Chat View @@ -236,6 +236,9 @@ /* Location: ChannelInfoSheet.swift - Footer explaining delete action */ "chats.channelInfo.deleteFooter" = "Deleting removes this channel from your device. You can rejoin later if you have the secret key."; +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Channel Deletion Failed"; + /* Location: ChannelInfoSheet.swift - Error when device not connected */ "chats.error.noDeviceConnected" = "No device connected"; @@ -748,6 +751,12 @@ /* Location: ChatInputBar.swift - Accessibility hint when message is empty */ "chats.input.typeFirst" = "Type a message first"; +/* Location: ChatInputBar.swift - Accessibility label for encrypted indicator */ +"chats.input.encrypted" = "Encrypted"; + +/* Location: ChatInputBar.swift - Accessibility label for not encrypted indicator */ +"chats.input.notEncrypted" = "Not encrypted"; + // MARK: - Message Path Sheet /* Location: MessagePathSheet.swift - Empty state title */ diff --git a/PocketMesh/Resources/Localization/en.lproj/Contacts.strings b/PocketMesh/Resources/Localization/en.lproj/Contacts.strings index 7137eece..eb74b50a 100644 --- a/PocketMesh/Resources/Localization/en.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/en.lproj/Contacts.strings @@ -748,7 +748,7 @@ // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Code input placeholder */ -"contacts.trace.list.codePlaceholder" = "Example: A1, 2B, 9S"; +"contacts.trace.list.codePlaceholder" = "Example: A1, 2B"; /* Location: TracePathListView.swift - Purpose: Paste button */ "contacts.trace.list.paste" = "Paste from clipboard"; diff --git a/PocketMesh/Resources/Localization/en.lproj/Onboarding.strings b/PocketMesh/Resources/Localization/en.lproj/Onboarding.strings index 7805b7a3..ba9e4f26 100644 --- a/PocketMesh/Resources/Localization/en.lproj/Onboarding.strings +++ b/PocketMesh/Resources/Localization/en.lproj/Onboarding.strings @@ -117,6 +117,9 @@ /* Location: DeviceScanView.swift - Button to add a new device */ "deviceScan.addDevice" = "Add Device"; +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Retry Connection"; + /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "Device not appearing?"; diff --git a/PocketMesh/Resources/Localization/en.lproj/Settings.strings b/PocketMesh/Resources/Localization/en.lproj/Settings.strings index 12bcf6a5..c4402798 100644 --- a/PocketMesh/Resources/Localization/en.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/en.lproj/Settings.strings @@ -504,6 +504,27 @@ /* Description for overwrite oldest toggle */ "nodes.overwriteOldestDescription" = "When storage is full, replace the oldest non-favorite node"; +/* Picker label for max hop distance */ +"nodes.maxHops" = "Max Hop Distance"; + +/* Description for max hop distance picker */ +"nodes.maxHopsDescription" = "Restrict which nodes are auto-added based on their hop count."; + +/* No limit option */ +"nodes.maxHops.noLimit" = "No Limit"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Direct Only"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 Hop"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d Hops"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "Nodes beyond the selected hop distance will not be auto-added."; + // MARK: - Auto-Remove Old Nodes Section @@ -739,6 +760,15 @@ /* GPS source option: device GPS */ "location.gpsSource.device" = "Device GPS"; +/* Section header for device GPS controls */ +"location.deviceGps.header" = "Device GPS"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Enable Device GPS"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Turns the radio's built-in GPS on or off. Saving a manual map location turns Device GPS off."; + /* Detail text when location is being shared publicly */ "location.sharingPublicly" = "Sharing publicly"; diff --git a/PocketMesh/Resources/Localization/es.lproj/Chats.strings b/PocketMesh/Resources/Localization/es.lproj/Chats.strings index 17ca5317..891ae6cb 100644 --- a/PocketMesh/Resources/Localization/es.lproj/Chats.strings +++ b/PocketMesh/Resources/Localization/es.lproj/Chats.strings @@ -155,7 +155,7 @@ "chats.contactInfo.hasLocation" = "Tiene ubicación"; /* Location: ChatView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "Mensaje privado"; +"chats.input.placeholder.directMessage" = "Mensaje directo"; // MARK: - Channel Chat View @@ -236,6 +236,9 @@ /* Location: ChannelInfoSheet.swift - Footer explaining delete action */ "chats.channelInfo.deleteFooter" = "Eliminar quita este canal de tu dispositivo. Puedes volver a unirte más tarde si tienes la clave secreta."; +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Error al eliminar el canal"; + /* Location: ChannelInfoSheet.swift - Error when device not connected */ "chats.error.noDeviceConnected" = "Dispositivo no conectado"; @@ -681,6 +684,8 @@ /* Location: ChatInputBar.swift - Accessibility hint when message is empty */ "chats.input.typeFirst" = "Escribe un mensaje primero"; +"chats.input.encrypted" = "Cifrado"; +"chats.input.notEncrypted" = "No cifrado"; // MARK: - Message Path Sheet diff --git a/PocketMesh/Resources/Localization/es.lproj/Contacts.strings b/PocketMesh/Resources/Localization/es.lproj/Contacts.strings index 66d37be7..84ed7a7e 100644 --- a/PocketMesh/Resources/Localization/es.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/es.lproj/Contacts.strings @@ -778,7 +778,7 @@ // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Code input placeholder */ -"contacts.trace.list.codePlaceholder" = "Ejemplo: A1, 2B, 9S"; +"contacts.trace.list.codePlaceholder" = "Ejemplo: A1, 2B"; /* Location: TracePathListView.swift - Purpose: Paste button */ "contacts.trace.list.paste" = "Pegar del portapapeles"; diff --git a/PocketMesh/Resources/Localization/es.lproj/Onboarding.strings b/PocketMesh/Resources/Localization/es.lproj/Onboarding.strings index efebc184..a5d4a6d5 100644 --- a/PocketMesh/Resources/Localization/es.lproj/Onboarding.strings +++ b/PocketMesh/Resources/Localization/es.lproj/Onboarding.strings @@ -117,6 +117,9 @@ /* Location: DeviceScanView.swift - Button to add a new device */ "deviceScan.addDevice" = "Añadir dispositivo"; +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Reintentar conexión"; + /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "¿El dispositivo no aparece?"; diff --git a/PocketMesh/Resources/Localization/es.lproj/Settings.strings b/PocketMesh/Resources/Localization/es.lproj/Settings.strings index 32b6c708..43e5b3c2 100644 --- a/PocketMesh/Resources/Localization/es.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/es.lproj/Settings.strings @@ -504,6 +504,26 @@ /* Description for overwrite oldest toggle */ "nodes.overwriteOldestDescription" = "Cuando el almacenamiento está lleno, reemplazar el nodo no favorito más antiguo"; +/* Picker label for max hop distance */ +"nodes.maxHops" = "Distancia máx. de saltos"; +/* Description for max hop distance picker */ +"nodes.maxHopsDescription" = "Restringir qué nodos se añaden automáticamente según su número de saltos."; + +/* No limit option */ +"nodes.maxHops.noLimit" = "Sin límite"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Solo directo"; + +/* 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" = "Los nodos que superen la distancia de saltos seleccionada no se añadirán automáticamente."; + // MARK: - Auto-Remove Old Nodes Section @@ -978,6 +998,15 @@ /* GPS source option: device GPS */ "location.gpsSource.device" = "GPS del dispositivo"; +/* Section header for device GPS controls */ +"location.deviceGps.header" = "GPS del dispositivo"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Activar GPS del dispositivo"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Activa o desactiva el GPS integrado del radio. Guardar una ubicación manual del mapa desactiva el GPS del dispositivo."; + /* Detail text when location is being shared publicly */ "location.sharingPublicly" = "Compartiendo públicamente"; diff --git a/PocketMesh/Resources/Localization/fr.lproj/Chats.strings b/PocketMesh/Resources/Localization/fr.lproj/Chats.strings index 6e217fe8..362d84d2 100644 --- a/PocketMesh/Resources/Localization/fr.lproj/Chats.strings +++ b/PocketMesh/Resources/Localization/fr.lproj/Chats.strings @@ -155,7 +155,7 @@ "chats.contactInfo.hasLocation" = "A une position"; /* Location: ChatView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "Message privé"; +"chats.input.placeholder.directMessage" = "Message direct"; // MARK: - Channel Chat View @@ -236,6 +236,9 @@ /* Location: ChannelInfoSheet.swift - Footer explaining delete action */ "chats.channelInfo.deleteFooter" = "La suppression retire ce canal de votre appareil. Vous pourrez le rejoindre plus tard si vous avez la clé secrète."; +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Échec de la suppression du canal"; + /* Location: ChannelInfoSheet.swift - Error when device not connected */ "chats.error.noDeviceConnected" = "Aucun appareil connecté"; @@ -681,6 +684,8 @@ /* Location: ChatInputBar.swift - Accessibility hint when message is empty */ "chats.input.typeFirst" = "Tapez d'abord un message"; +"chats.input.encrypted" = "Chiffré"; +"chats.input.notEncrypted" = "Non chiffré"; // MARK: - Message Path Sheet diff --git a/PocketMesh/Resources/Localization/fr.lproj/Contacts.strings b/PocketMesh/Resources/Localization/fr.lproj/Contacts.strings index e5817a85..448bbb4c 100644 --- a/PocketMesh/Resources/Localization/fr.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/fr.lproj/Contacts.strings @@ -778,7 +778,7 @@ // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Code input placeholder */ -"contacts.trace.list.codePlaceholder" = "Exemple : A1, 2B, 9S"; +"contacts.trace.list.codePlaceholder" = "Exemple : A1, 2B"; /* Location: TracePathListView.swift - Purpose: Paste button */ "contacts.trace.list.paste" = "Coller depuis le presse-papiers"; diff --git a/PocketMesh/Resources/Localization/fr.lproj/Onboarding.strings b/PocketMesh/Resources/Localization/fr.lproj/Onboarding.strings index 933f44b3..95233d02 100644 --- a/PocketMesh/Resources/Localization/fr.lproj/Onboarding.strings +++ b/PocketMesh/Resources/Localization/fr.lproj/Onboarding.strings @@ -117,6 +117,9 @@ /* Location: DeviceScanView.swift - Button to add a new device */ "deviceScan.addDevice" = "Ajouter un appareil"; +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Réessayer la connexion"; + /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "L'appareil n'apparaît pas ?"; diff --git a/PocketMesh/Resources/Localization/fr.lproj/Settings.strings b/PocketMesh/Resources/Localization/fr.lproj/Settings.strings index ea3d18ee..24ed03f7 100644 --- a/PocketMesh/Resources/Localization/fr.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/fr.lproj/Settings.strings @@ -504,6 +504,26 @@ /* Description for overwrite oldest toggle */ "nodes.overwriteOldestDescription" = "Lorsque le stockage est plein, remplacer le nœud non favori le plus ancien"; +/* Picker label for max hop distance */ +"nodes.maxHops" = "Distance max. de sauts"; +/* Description for max hop distance picker */ +"nodes.maxHopsDescription" = "Limiter les nœuds ajoutés automatiquement selon leur nombre de sauts."; + +/* No limit option */ +"nodes.maxHops.noLimit" = "Aucune limite"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Direct uniquement"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 saut"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d sauts"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "Les nœuds au-delà de la distance de sauts sélectionnée ne seront pas ajoutés automatiquement."; + // MARK: - Auto-Remove Old Nodes Section @@ -978,6 +998,15 @@ /* GPS source option: device GPS */ "location.gpsSource.device" = "GPS de l'appareil"; +/* Section header for device GPS controls */ +"location.deviceGps.header" = "GPS de l'appareil"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Activer le GPS de l'appareil"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Active ou désactive le GPS intégré de la radio. L'enregistrement d'un emplacement manuel sur la carte désactive le GPS de l'appareil."; + /* Detail text when location is being shared publicly */ "location.sharingPublicly" = "Partage public"; diff --git a/PocketMesh/Resources/Localization/nl.lproj/Chats.strings b/PocketMesh/Resources/Localization/nl.lproj/Chats.strings index 572db7a4..3a3c4f25 100644 --- a/PocketMesh/Resources/Localization/nl.lproj/Chats.strings +++ b/PocketMesh/Resources/Localization/nl.lproj/Chats.strings @@ -155,7 +155,7 @@ "chats.contactInfo.hasLocation" = "Heeft locatie"; /* Location: ChatView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "Privébericht"; +"chats.input.placeholder.directMessage" = "Direct bericht"; // MARK: - Channel Chat View @@ -236,6 +236,9 @@ /* Location: ChannelInfoSheet.swift - Footer explaining delete action */ "chats.channelInfo.deleteFooter" = "Verwijderen haalt dit kanaal van je apparaat. Je kunt later opnieuw deelnemen als je de geheime sleutel hebt."; +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Kanaal verwijderen mislukt"; + /* Location: ChannelInfoSheet.swift - Error when device not connected */ "chats.error.noDeviceConnected" = "Geen apparaat verbonden"; @@ -681,6 +684,8 @@ /* Location: ChatInputBar.swift - Accessibility hint when message is empty */ "chats.input.typeFirst" = "Typ eerst een bericht"; +"chats.input.encrypted" = "Versleuteld"; +"chats.input.notEncrypted" = "Niet versleuteld"; // MARK: - Message Path Sheet diff --git a/PocketMesh/Resources/Localization/nl.lproj/Contacts.strings b/PocketMesh/Resources/Localization/nl.lproj/Contacts.strings index cae88697..c69b7833 100644 --- a/PocketMesh/Resources/Localization/nl.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/nl.lproj/Contacts.strings @@ -748,7 +748,7 @@ // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Code input placeholder */ -"contacts.trace.list.codePlaceholder" = "Voorbeeld: A1, 2B, 9S"; +"contacts.trace.list.codePlaceholder" = "Voorbeeld: A1, 2B"; /* Location: TracePathListView.swift - Purpose: Paste button */ "contacts.trace.list.paste" = "Plakken van klembord"; diff --git a/PocketMesh/Resources/Localization/nl.lproj/Onboarding.strings b/PocketMesh/Resources/Localization/nl.lproj/Onboarding.strings index 09bd956c..1264eedf 100644 --- a/PocketMesh/Resources/Localization/nl.lproj/Onboarding.strings +++ b/PocketMesh/Resources/Localization/nl.lproj/Onboarding.strings @@ -117,6 +117,9 @@ /* Location: DeviceScanView.swift - Button to add a new device */ "deviceScan.addDevice" = "Apparaat toevoegen"; +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Opnieuw verbinden"; + /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "Apparaat verschijnt niet?"; diff --git a/PocketMesh/Resources/Localization/nl.lproj/Settings.strings b/PocketMesh/Resources/Localization/nl.lproj/Settings.strings index 15243bf8..85ed3e83 100644 --- a/PocketMesh/Resources/Localization/nl.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/nl.lproj/Settings.strings @@ -504,6 +504,26 @@ /* Description for overwrite oldest toggle */ "nodes.overwriteOldestDescription" = "Wanneer de opslag vol is, het oudste niet-favoriete knooppunt vervangen"; +/* Picker label for max hop distance */ +"nodes.maxHops" = "Max. hopafstand"; +/* Description for max hop distance picker */ +"nodes.maxHopsDescription" = "Beperk welke knooppunten automatisch worden toegevoegd op basis van hun hopafstand."; + +/* No limit option */ +"nodes.maxHops.noLimit" = "Geen limiet"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Alleen direct"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 hop"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d hops"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "Knooppunten voorbij de geselecteerde hopafstand worden niet automatisch toegevoegd."; + // MARK: - Auto-Remove Old Nodes Section @@ -978,6 +998,15 @@ /* GPS source option: device GPS */ "location.gpsSource.device" = "Apparaat GPS"; +/* Section header for device GPS controls */ +"location.deviceGps.header" = "Apparaat GPS"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Apparaat GPS inschakelen"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Schakelt de ingebouwde GPS van de radio in of uit. Het opslaan van een handmatige kaartlocatie schakelt de apparaat GPS uit."; + /* Detail text when location is being shared publicly */ "location.sharingPublicly" = "Openbaar delen"; diff --git a/PocketMesh/Resources/Localization/pl.lproj/Chats.strings b/PocketMesh/Resources/Localization/pl.lproj/Chats.strings index 6ff8d807..9f7dcca1 100644 --- a/PocketMesh/Resources/Localization/pl.lproj/Chats.strings +++ b/PocketMesh/Resources/Localization/pl.lproj/Chats.strings @@ -155,7 +155,7 @@ "chats.contactInfo.hasLocation" = "Ma lokalizację"; /* Location: ChatView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "Prywatna wiadomość"; +"chats.input.placeholder.directMessage" = "Wiadomość bezpośrednia"; // MARK: - Channel Chat View @@ -236,6 +236,9 @@ /* Location: ChannelInfoSheet.swift - Footer explaining delete action */ "chats.channelInfo.deleteFooter" = "Usunięcie usuwa ten kanał z urządzenia. Możesz dołączyć ponownie później, jeśli masz klucz tajny."; +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Nie udało się usunąć kanału"; + /* Location: ChannelInfoSheet.swift - Error when device not connected */ "chats.error.noDeviceConnected" = "Brak połączonego urządzenia"; @@ -676,6 +679,8 @@ /* Location: ChatInputBar.swift - Accessibility hint when message is empty */ "chats.input.typeFirst" = "Najpierw wpisz wiadomość"; +"chats.input.encrypted" = "Zaszyfrowane"; +"chats.input.notEncrypted" = "Niezaszyfrowane"; // MARK: - Message Path Sheet diff --git a/PocketMesh/Resources/Localization/pl.lproj/Contacts.strings b/PocketMesh/Resources/Localization/pl.lproj/Contacts.strings index e1110f5a..4c502d33 100644 --- a/PocketMesh/Resources/Localization/pl.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/pl.lproj/Contacts.strings @@ -765,7 +765,7 @@ // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Code input placeholder */ -"contacts.trace.list.codePlaceholder" = "Przykład: A1, 2B, 9S"; +"contacts.trace.list.codePlaceholder" = "Przykład: A1, 2B"; /* Location: TracePathListView.swift - Purpose: Paste button */ "contacts.trace.list.paste" = "Wklej ze schowka"; diff --git a/PocketMesh/Resources/Localization/pl.lproj/Onboarding.strings b/PocketMesh/Resources/Localization/pl.lproj/Onboarding.strings index 8d91417a..5cb5d30c 100644 --- a/PocketMesh/Resources/Localization/pl.lproj/Onboarding.strings +++ b/PocketMesh/Resources/Localization/pl.lproj/Onboarding.strings @@ -117,6 +117,9 @@ /* Location: DeviceScanView.swift - Button to add a new device */ "deviceScan.addDevice" = "Dodaj urządzenie"; +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Ponów połączenie"; + /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "Urządzenie się nie pojawia?"; diff --git a/PocketMesh/Resources/Localization/pl.lproj/Settings.strings b/PocketMesh/Resources/Localization/pl.lproj/Settings.strings index c228cbd6..c44d9d03 100644 --- a/PocketMesh/Resources/Localization/pl.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/pl.lproj/Settings.strings @@ -504,6 +504,26 @@ /* Description for overwrite oldest toggle */ "nodes.overwriteOldestDescription" = "Gdy pamięć jest pełna, zastąp najstarszy nieulubiony węzeł"; +/* Picker label for max hop distance */ +"nodes.maxHops" = "Maks. odległość skoków"; +/* Description for max hop distance picker */ +"nodes.maxHopsDescription" = "Ogranicz automatyczne dodawanie węzłów na podstawie ich liczby skoków."; + +/* No limit option */ +"nodes.maxHops.noLimit" = "Bez limitu"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Tylko bezpośrednio"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 skok"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d skoków"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "Węzły przekraczające wybraną odległość skoków nie zostaną automatycznie dodane."; + // MARK: - Auto-Remove Old Nodes Section @@ -978,6 +998,15 @@ /* GPS source option: device GPS */ "location.gpsSource.device" = "GPS urządzenia"; +/* Section header for device GPS controls */ +"location.deviceGps.header" = "GPS urządzenia"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Włącz GPS urządzenia"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Włącza lub wyłącza wbudowany GPS radia. Zapisanie ręcznej lokalizacji na mapie wyłącza GPS urządzenia."; + /* Detail text when location is being shared publicly */ "location.sharingPublicly" = "Udostępnianie publiczne"; diff --git a/PocketMesh/Resources/Localization/ru.lproj/Chats.strings b/PocketMesh/Resources/Localization/ru.lproj/Chats.strings index 2631db1f..7b9d7198 100644 --- a/PocketMesh/Resources/Localization/ru.lproj/Chats.strings +++ b/PocketMesh/Resources/Localization/ru.lproj/Chats.strings @@ -154,7 +154,7 @@ "chats.contactInfo.hasLocation" = "Есть местоположение"; /* Location: ChatView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "Личное сообщение"; +"chats.input.placeholder.directMessage" = "Прямое сообщение"; // MARK: - Channel Chat View @@ -235,6 +235,9 @@ /* Location: ChannelInfoSheet.swift - Footer explaining delete action */ "chats.channelInfo.deleteFooter" = "Удаление убирает этот канал с твоего устройства. Ты сможешь присоединиться снова, если есть секретный ключ."; +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Не удалось удалить канал"; + /* Location: ChannelInfoSheet.swift - Error when device not connected */ "chats.error.noDeviceConnected" = "Устройство не подключено"; @@ -675,6 +678,8 @@ /* Location: ChatInputBar.swift - Accessibility hint when message is empty */ "chats.input.typeFirst" = "Сначала введи сообщение"; +"chats.input.encrypted" = "Зашифровано"; +"chats.input.notEncrypted" = "Не зашифровано"; // MARK: - Message Path Sheet diff --git a/PocketMesh/Resources/Localization/ru.lproj/Contacts.strings b/PocketMesh/Resources/Localization/ru.lproj/Contacts.strings index 67e1b1c9..33ccf35f 100644 --- a/PocketMesh/Resources/Localization/ru.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/ru.lproj/Contacts.strings @@ -765,7 +765,7 @@ // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Code input placeholder */ -"contacts.trace.list.codePlaceholder" = "Пример: A1, 2B, 9S"; +"contacts.trace.list.codePlaceholder" = "Пример: A1, 2B"; /* Location: TracePathListView.swift - Purpose: Paste button */ "contacts.trace.list.paste" = "Вставить из буфера обмена"; diff --git a/PocketMesh/Resources/Localization/ru.lproj/Onboarding.strings b/PocketMesh/Resources/Localization/ru.lproj/Onboarding.strings index 2b3d0bac..251ebdff 100644 --- a/PocketMesh/Resources/Localization/ru.lproj/Onboarding.strings +++ b/PocketMesh/Resources/Localization/ru.lproj/Onboarding.strings @@ -117,6 +117,9 @@ /* Location: DeviceScanView.swift - Button to add a new device */ "deviceScan.addDevice" = "Добавить устройство"; +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Повторить подключение"; + /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "Устройство не отображается?"; diff --git a/PocketMesh/Resources/Localization/ru.lproj/Settings.strings b/PocketMesh/Resources/Localization/ru.lproj/Settings.strings index f0bb1a73..4799a4da 100644 --- a/PocketMesh/Resources/Localization/ru.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/ru.lproj/Settings.strings @@ -504,6 +504,26 @@ /* Description for overwrite oldest toggle */ "nodes.overwriteOldestDescription" = "Когда хранилище заполнено, заменить старейший неизбранный узел"; +/* Picker label for max hop distance */ +"nodes.maxHops" = "Макс. расстояние хопов"; +/* Description for max hop distance picker */ +"nodes.maxHopsDescription" = "Ограничить автоматическое добавление узлов на основе количества переходов."; + +/* No limit option */ +"nodes.maxHops.noLimit" = "Без ограничений"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Только прямые"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 хоп"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d хопов"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "Узлы за пределами выбранного расстояния хопов не будут добавлены автоматически."; + // MARK: - Auto-Remove Old Nodes Section @@ -978,6 +998,15 @@ /* GPS source option: device GPS */ "location.gpsSource.device" = "GPS устройства"; +/* Section header for device GPS controls */ +"location.deviceGps.header" = "GPS устройства"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Включить GPS устройства"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Включает или выключает встроенный GPS радиостанции. Сохранение местоположения вручную на карте выключает GPS устройства."; + /* Detail text when location is being shared publicly */ "location.sharingPublicly" = "Публичный доступ"; diff --git a/PocketMesh/Resources/Localization/uk.lproj/Chats.strings b/PocketMesh/Resources/Localization/uk.lproj/Chats.strings index ef133cff..3ddfb8e1 100644 --- a/PocketMesh/Resources/Localization/uk.lproj/Chats.strings +++ b/PocketMesh/Resources/Localization/uk.lproj/Chats.strings @@ -154,7 +154,7 @@ "chats.contactInfo.hasLocation" = "Має місцезнаходження"; /* Location: ChatView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "Особисте повідомлення"; +"chats.input.placeholder.directMessage" = "Пряме повідомлення"; // MARK: - Channel Chat View @@ -235,6 +235,9 @@ /* Location: ChannelInfoSheet.swift - Footer explaining delete action */ "chats.channelInfo.deleteFooter" = "Видалення прибирає цей канал з твого пристрою. Ти зможеш приєднатися знову, якщо маєш секретний ключ."; +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "Не вдалося видалити канал"; + /* Location: ChannelInfoSheet.swift - Error when device not connected */ "chats.error.noDeviceConnected" = "Пристрій не підключено"; @@ -675,6 +678,8 @@ /* Location: ChatInputBar.swift - Accessibility hint when message is empty */ "chats.input.typeFirst" = "Спочатку введи повідомлення"; +"chats.input.encrypted" = "Зашифровано"; +"chats.input.notEncrypted" = "Не зашифровано"; // MARK: - Message Path Sheet diff --git a/PocketMesh/Resources/Localization/uk.lproj/Contacts.strings b/PocketMesh/Resources/Localization/uk.lproj/Contacts.strings index 806b0af0..e527b210 100644 --- a/PocketMesh/Resources/Localization/uk.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/uk.lproj/Contacts.strings @@ -765,7 +765,7 @@ // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Code input placeholder */ -"contacts.trace.list.codePlaceholder" = "Приклад: A1, 2B, 9S"; +"contacts.trace.list.codePlaceholder" = "Приклад: A1, 2B"; /* Location: TracePathListView.swift - Purpose: Paste button */ "contacts.trace.list.paste" = "Вставити з буфера обміну"; diff --git a/PocketMesh/Resources/Localization/uk.lproj/Onboarding.strings b/PocketMesh/Resources/Localization/uk.lproj/Onboarding.strings index 3d18c9d3..69f28c18 100644 --- a/PocketMesh/Resources/Localization/uk.lproj/Onboarding.strings +++ b/PocketMesh/Resources/Localization/uk.lproj/Onboarding.strings @@ -117,6 +117,9 @@ /* Location: DeviceScanView.swift - Button to add a new device */ "deviceScan.addDevice" = "Додати пристрій"; +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "Повторити з'єднання"; + /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "Пристрій не з'являється?"; diff --git a/PocketMesh/Resources/Localization/uk.lproj/Settings.strings b/PocketMesh/Resources/Localization/uk.lproj/Settings.strings index bbbb10b2..427382cd 100644 --- a/PocketMesh/Resources/Localization/uk.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/uk.lproj/Settings.strings @@ -504,6 +504,26 @@ /* Description for overwrite oldest toggle */ "nodes.overwriteOldestDescription" = "Коли сховище заповнене, замінити найстаріший необраний вузол"; +/* Picker label for max hop distance */ +"nodes.maxHops" = "Макс. відстань хопів"; +/* Description for max hop distance picker */ +"nodes.maxHopsDescription" = "Обмежити автоматичне додавання вузлів на основі кількості переходів."; + +/* No limit option */ +"nodes.maxHops.noLimit" = "Без обмежень"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "Тільки прямі"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 хоп"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d хопів"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "Вузли за межами обраної відстані хопів не будуть додані автоматично."; + // MARK: - Auto-Remove Old Nodes Section @@ -978,6 +998,15 @@ /* GPS source option: device GPS */ "location.gpsSource.device" = "GPS пристрою"; +/* Section header for device GPS controls */ +"location.deviceGps.header" = "GPS пристрою"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "Увімкнути GPS пристрою"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "Вмикає або вимикає вбудований GPS радіостанції. Збереження місцезнаходження вручну на мапі вимикає GPS пристрою."; + /* Detail text when location is being shared publicly */ "location.sharingPublicly" = "Публічний доступ"; diff --git a/PocketMesh/Resources/Localization/zh-Hans.lproj/Chats.strings b/PocketMesh/Resources/Localization/zh-Hans.lproj/Chats.strings index 28115f4c..e84f8903 100644 --- a/PocketMesh/Resources/Localization/zh-Hans.lproj/Chats.strings +++ b/PocketMesh/Resources/Localization/zh-Hans.lproj/Chats.strings @@ -155,7 +155,7 @@ "chats.contactInfo.hasLocation" = "有位置"; /* Location: ChatView.swift - Input bar placeholder for direct messages */ -"chats.input.placeholder.directMessage" = "私密消息"; +"chats.input.placeholder.directMessage" = "私信"; // MARK: - Channel Chat View @@ -236,6 +236,9 @@ /* Location: ChannelInfoSheet.swift - Footer explaining delete action */ "chats.channelInfo.deleteFooter" = "删除频道,会将从您的设备中移除此频道,如果您有密钥,稍后也可以重新加入。"; +/* Location: ChatsView.swift - Alert title when channel deletion fails */ +"chats.channelInfo.deleteFailed.title" = "频道删除失败"; + /* Location: ChannelInfoSheet.swift - Error when device not connected */ "chats.error.noDeviceConnected" = "未连接设备"; @@ -747,6 +750,8 @@ /* Location: ChatInputBar.swift - Accessibility hint when message is empty */ "chats.input.typeFirst" = "请先输入消息"; +"chats.input.encrypted" = "已加密"; +"chats.input.notEncrypted" = "未加密"; // MARK: - Message Path Sheet diff --git a/PocketMesh/Resources/Localization/zh-Hans.lproj/Contacts.strings b/PocketMesh/Resources/Localization/zh-Hans.lproj/Contacts.strings index abe39b95..3b682d6b 100644 --- a/PocketMesh/Resources/Localization/zh-Hans.lproj/Contacts.strings +++ b/PocketMesh/Resources/Localization/zh-Hans.lproj/Contacts.strings @@ -748,7 +748,7 @@ // MARK: - Trace Path List View /* Location: TracePathListView.swift - Purpose: Code input placeholder */ -"contacts.trace.list.codePlaceholder" = "示例:A1、2B、9S"; +"contacts.trace.list.codePlaceholder" = "示例:A1、2B"; /* Location: TracePathListView.swift - Purpose: Paste button */ "contacts.trace.list.paste" = "从剪贴板粘贴"; diff --git a/PocketMesh/Resources/Localization/zh-Hans.lproj/Onboarding.strings b/PocketMesh/Resources/Localization/zh-Hans.lproj/Onboarding.strings index 4053355f..c48b231a 100644 --- a/PocketMesh/Resources/Localization/zh-Hans.lproj/Onboarding.strings +++ b/PocketMesh/Resources/Localization/zh-Hans.lproj/Onboarding.strings @@ -115,6 +115,9 @@ /* Location: DeviceScanView.swift - Button to add a new device */ "deviceScan.addDevice" = "添加设备"; +/* Location: DeviceScanView.swift - Button to retry connection after other-app conflict */ +"deviceScan.retryConnection" = "重试连接"; + /* Location: DeviceScanView.swift - Button for troubleshooting */ "deviceScan.deviceNotAppearing" = "设备未显示?"; diff --git a/PocketMesh/Resources/Localization/zh-Hans.lproj/Settings.strings b/PocketMesh/Resources/Localization/zh-Hans.lproj/Settings.strings index c0b3aa35..268ffd42 100644 --- a/PocketMesh/Resources/Localization/zh-Hans.lproj/Settings.strings +++ b/PocketMesh/Resources/Localization/zh-Hans.lproj/Settings.strings @@ -491,6 +491,26 @@ /* Description for overwrite oldest toggle */ "nodes.overwriteOldestDescription" = "当存储已满时,替换最旧的非收藏节点"; +/* Picker label for max hop distance */ +"nodes.maxHops" = "最大跳数距离"; +/* Description for max hop distance picker */ +"nodes.maxHopsDescription" = "根据跳数限制自动添加的节点。"; + +/* No limit option */ +"nodes.maxHops.noLimit" = "无限制"; + +/* Direct only option (0 hops) */ +"nodes.maxHops.directOnly" = "仅直连"; + +/* Singular 1 hop option */ +"nodes.maxHops.oneHop" = "1 跳"; + +/* Plural hops format string */ +"nodes.maxHops.hops" = "%d 跳"; + +/* Footer text when hop limit is active */ +"nodes.maxHops.footerActive" = "超出所选跳数距离的节点将不会被自动添加。"; + // MARK: - Auto-Remove Old Nodes Section @@ -709,6 +729,15 @@ /* GPS source option: device GPS */ "location.gpsSource.device" = "设备 GPS"; +/* Section header for device GPS controls */ +"location.deviceGps.header" = "设备 GPS"; + +/* Toggle label for device GPS power */ +"location.deviceGps.toggle" = "启用设备 GPS"; + +/* Footer for device GPS controls */ +"location.deviceGps.footer" = "打开或关闭无线电的内置 GPS。保存手动地图位置将关闭设备 GPS。"; + /* Detail text when location is being shared publicly */ "location.sharingPublicly" = "公开分享"; diff --git a/PocketMesh/Services/LinkPreviewService.swift b/PocketMesh/Services/LinkPreviewService.swift index edfd168e..cadfb15c 100644 --- a/PocketMesh/Services/LinkPreviewService.swift +++ b/PocketMesh/Services/LinkPreviewService.swift @@ -72,11 +72,14 @@ final class LinkPreviewService: Sendable { return nil } + /// Cached mention regex to avoid re-creating on every call + private static let mentionRegex: NSRegularExpression? = { + try? NSRegularExpression(pattern: MentionUtilities.mentionPattern) + }() + /// Extracts ranges of all mentions in the text (format: @[name]) private static func extractMentionRanges(from text: String) -> [NSRange] { - guard let regex = try? NSRegularExpression(pattern: MentionUtilities.mentionPattern) else { - return [] - } + guard let regex = mentionRegex else { return [] } let range = NSRange(text.startIndex..., in: text) return regex.matches(in: text, range: range).map(\.range) } diff --git a/PocketMesh/State/AppState.swift b/PocketMesh/State/AppState.swift index 6d4e3ed1..be8b977b 100644 --- a/PocketMesh/State/AppState.swift +++ b/PocketMesh/State/AppState.swift @@ -309,7 +309,7 @@ public final class AppState { await MainActor.run { self.connectionManager.updateAutoAddConfig(config) // Clear storage full flag when overwrite oldest is enabled (bit 0x01) - if config & 0x01 != 0 { + if config.bitmask & 0x01 != 0 { self.connectionUI.isNodeStorageFull = false } } diff --git a/PocketMesh/Views/Chats/ChannelChatView.swift b/PocketMesh/Views/Chats/ChannelChatView.swift index 52d5c482..915f1a17 100644 --- a/PocketMesh/Views/Chats/ChannelChatView.swift +++ b/PocketMesh/Views/Chats/ChannelChatView.swift @@ -14,6 +14,7 @@ private struct BlockSenderContext: Identifiable { struct ChannelChatView: View { @Environment(\.appState) private var appState @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.linkPreviewCache) private var linkPreviewCache let channel: ChannelDTO @@ -30,11 +31,6 @@ struct ChannelChatView: View { @State private var mentionScrollTask: Task? @State private var scrollToDividerRequest = 0 @State private var isDividerVisible = false - @State private var hasDismissedDividerFAB = false - - private var showDividerFAB: Bool { - viewModel.newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerFAB - } @State private var selectedMessageForActions: MessageDTO? @State private var blockSenderContext: BlockSenderContext? @@ -46,6 +42,8 @@ struct ChannelChatView: View { @AppStorage("showInlineImages") private var showInlineImages = true @AppStorage("autoPlayGIFs") private var autoPlayGIFs = true + @AppStorage("showIncomingPath") private var showIncomingPath = false + @AppStorage("showIncomingHopCount") private var showIncomingHopCount = false init(channel: ChannelDTO, parentViewModel: ChatViewModel? = nil) { self.channel = channel @@ -103,6 +101,7 @@ struct ChannelChatView: View { handleMessageAction(action, for: message) } ) + .environment(\.horizontalSizeClass, horizontalSizeClass) } .sheet(item: $blockSenderContext) { context in BlockSenderSheet( @@ -350,165 +349,30 @@ struct ChannelChatView: View { // MARK: - Messages View private var messagesView: some View { - Group { - if !viewModel.hasLoadedOnce { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.messages.isEmpty { - emptyMessagesView - } else { - let mentionIDSet = Set(unseenMentionIDs) - ChatTableView( - items: viewModel.displayItems, - cellContent: { displayItem in - messageBubble(for: displayItem) - }, - isAtBottom: $isAtBottom, - unreadCount: $unreadCount, - scrollToBottomRequest: $scrollToBottomRequest, - scrollToMentionRequest: $scrollToMentionRequest, - isUnseenMention: { displayItem in - displayItem.containsSelfMention && !displayItem.mentionSeen && mentionIDSet.contains(displayItem.id) - }, - onMentionBecameVisible: { messageID in - Task { - await markMentionSeen(messageID: messageID) - } - }, - mentionTargetID: scrollToTargetID, - scrollToDividerRequest: $scrollToDividerRequest, - dividerItemID: viewModel.newMessagesDividerMessageID, - isDividerVisible: $isDividerVisible, - onNearTop: { - Task { - await viewModel.loadOlderMessages() - } - }, - isLoadingOlderMessages: viewModel.isLoadingOlder - ) - .overlay(alignment: .bottomTrailing) { - VStack(spacing: 12) { - if showDividerFAB { - ScrollToDividerButton( - onTap: { - scrollToDividerRequest += 1 - hasDismissedDividerFAB = true - } - ) - .transition(.scale.combined(with: .opacity)) - } - - if !unseenMentionIDs.isEmpty { - ScrollToMentionButton( - unreadMentionCount: unseenMentionIDs.count, - onTap: { scrollToNextMention() } - ) - .transition(.scale.combined(with: .opacity)) - } - - ScrollToBottomButton( - isVisible: !isAtBottom, - unreadCount: unreadCount, - onTap: { scrollToBottomRequest += 1 } - ) - } - .animation(.snappy(duration: 0.2), value: showDividerFAB) - .animation(.snappy(duration: 0.2), value: unseenMentionIDs.isEmpty) - .padding(.trailing, 16) - .padding(.bottom, 8) - } - .onChange(of: viewModel.newMessagesDividerMessageID) { _, _ in - hasDismissedDividerFAB = false - } - } - } - } - - @ViewBuilder - private func messageBubble(for item: MessageDisplayItem) -> some View { - if let message = viewModel.message(for: item) { - UnifiedMessageBubble( - message: message, - contactName: channel.name.isEmpty ? L10n.Chats.Chats.Channel.defaultName(Int(channel.index)) : channel.name, - deviceName: appState.connectedDevice?.nodeName ?? "Me", - configuration: .channel( - isPublic: channel.isPublicChannel || channel.name.hasPrefix("#"), - contacts: viewModel.conversations - ), - displayState: MessageDisplayState( - showTimestamp: item.showTimestamp, - showDirectionGap: item.showDirectionGap, - showSenderName: item.showSenderName, - showNewMessagesDivider: item.showNewMessagesDivider, - previewState: item.previewState, - loadedPreview: item.loadedPreview, - isImageURL: item.isImageURL, - decodedImage: viewModel.decodedImage(for: message.id), - isGIF: viewModel.isGIFImage(for: message.id), - showInlineImages: showInlineImages, - autoPlayGIFs: autoPlayGIFs - ), - callbacks: MessageBubbleCallbacks( - onRetry: { retryMessage(message) }, - onReaction: { emoji in - recentEmojisStore.recordUsage(emoji) - Task { await viewModel.sendReaction(emoji: emoji, to: message) } - }, - onLongPress: { selectedMessageForActions = message }, - onImageTap: { - if let data = viewModel.imageData(for: message.id) { - imageViewerData = ImageViewerData( - imageData: data, - isGIF: false - ) - } - }, - onRetryImageFetch: { - Task { await viewModel.retryImageFetch(for: message.id) } - }, - onRequestPreviewFetch: { - if item.isImageURL && showInlineImages { - viewModel.requestImageFetch(for: message.id, showInlineImages: showInlineImages) - } else { - viewModel.requestPreviewFetch(for: message.id) - } - }, - onManualPreviewFetch: { - Task { - await viewModel.manualFetchPreview(for: message.id) - } - } - ) - ) - } else { - // ViewModel logs the warning for data inconsistency - Text(L10n.Chats.Chats.Message.unavailable) - .font(.caption) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) - } - } - - private var emptyMessagesView: some View { - VStack(spacing: 16) { - ChannelAvatar(channel: channel, size: 80) - - Text(channel.name.isEmpty ? L10n.Chats.Chats.Channel.defaultName(Int(channel.index)) : channel.name) - .font(.title2) - .bold() - - Text(L10n.Chats.Chats.Channel.EmptyState.noMessages) - .foregroundStyle(.secondary) - - Text(channel.isPublicChannel || channel.name.hasPrefix("#") ? L10n.Chats.Chats.Channel.EmptyState.publicDescription : L10n.Chats.Chats.Channel.EmptyState.privateDescription) - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - .onAppear { - logger.info("emptyMessagesView: appeared for channel \(channel.index), isLoading=\(viewModel.isLoading)") - } + ChannelMessagesContent( + viewModel: viewModel, + channel: channel, + deviceName: appState.connectedDevice?.nodeName ?? "Me", + showInlineImages: showInlineImages, + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + unseenMentionIDs: unseenMentionIDs, + scrollToTargetID: scrollToTargetID, + newMessagesDividerMessageID: viewModel.newMessagesDividerMessageID, + scrollToDividerRequest: $scrollToDividerRequest, + isDividerVisible: $isDividerVisible, + selectedMessageForActions: $selectedMessageForActions, + recentEmojisStore: recentEmojisStore, + imageViewerData: $imageViewerData, + onMentionSeen: { await markMentionSeen(messageID: $0) }, + onScrollToMention: { scrollToNextMention() }, + onRetryMessage: { retryMessage($0) } + ) } private func setReplyText(_ text: String) { @@ -621,7 +485,8 @@ struct ChannelChatView: View { text: $viewModel.composingText, isFocused: $isInputFocused, placeholder: channel.isPublicChannel || channel.name.hasPrefix("#") ? L10n.Chats.Chats.Channel.typePublic : L10n.Chats.Chats.Channel.typePrivate, - maxBytes: maxChannelMessageLength + maxBytes: maxChannelMessageLength, + isEncrypted: channel.isEncryptedChannel ) { text in scrollToBottomRequest += 1 Task { await viewModel.sendChannelMessage(text: text) } @@ -679,6 +544,217 @@ struct ChannelChatView: View { } } +// MARK: - Channel Messages Content + +private struct ChannelMessagesContent: View { + @Bindable var viewModel: ChatViewModel + let channel: ChannelDTO + let deviceName: String + let showInlineImages: Bool + let autoPlayGIFs: Bool + let showIncomingPath: Bool + let showIncomingHopCount: Bool + @Binding var isAtBottom: Bool + @Binding var unreadCount: Int + @Binding var scrollToBottomRequest: Int + @Binding var scrollToMentionRequest: Int + let unseenMentionIDs: [UUID] + let scrollToTargetID: UUID? + let newMessagesDividerMessageID: UUID? + @Binding var scrollToDividerRequest: Int + @Binding var isDividerVisible: Bool + @Binding var selectedMessageForActions: MessageDTO? + let recentEmojisStore: RecentEmojisStore + @Binding var imageViewerData: ImageViewerData? + let onMentionSeen: (UUID) async -> Void + let onScrollToMention: () -> Void + let onRetryMessage: (MessageDTO) -> Void + + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @State private var hasDismissedDividerFAB = false + + private var showDividerFAB: Bool { + newMessagesDividerMessageID != nil && !isDividerVisible && !hasDismissedDividerFAB + } + + var body: some View { + Group { + if !viewModel.hasLoadedOnce { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.messages.isEmpty { + ChannelEmptyMessagesView(channel: channel) + } else { + let mentionIDSet = Set(unseenMentionIDs) + let channelConfig = MessageBubbleConfiguration.channel( + isPublic: channel.isPublicChannel || channel.name.hasPrefix("#"), + contacts: viewModel.conversations + ) + ChatTableView( + items: viewModel.displayItems, + cellContent: { displayItem in + messageBubble(for: displayItem, configuration: channelConfig) + }, + isAtBottom: $isAtBottom, + unreadCount: $unreadCount, + scrollToBottomRequest: $scrollToBottomRequest, + scrollToMentionRequest: $scrollToMentionRequest, + isUnseenMention: { displayItem in + displayItem.containsSelfMention && !displayItem.mentionSeen && mentionIDSet.contains(displayItem.id) + }, + onMentionBecameVisible: { messageID in + Task { + await onMentionSeen(messageID) + } + }, + mentionTargetID: scrollToTargetID, + scrollToDividerRequest: $scrollToDividerRequest, + dividerItemID: newMessagesDividerMessageID, + isDividerVisible: $isDividerVisible, + onNearTop: { + Task { + await viewModel.loadOlderMessages() + } + }, + isLoadingOlderMessages: viewModel.isLoadingOlder + ) + .overlay(alignment: .bottomTrailing) { + VStack(spacing: 12) { + if showDividerFAB { + ScrollToDividerButton( + onTap: { + scrollToDividerRequest += 1 + hasDismissedDividerFAB = true + } + ) + .transition(.scale.combined(with: .opacity)) + } + + if !unseenMentionIDs.isEmpty { + ScrollToMentionButton( + unreadMentionCount: unseenMentionIDs.count, + onTap: { onScrollToMention() } + ) + .transition(.scale.combined(with: .opacity)) + } + + ScrollToBottomButton( + isVisible: !isAtBottom, + unreadCount: unreadCount, + onTap: { scrollToBottomRequest += 1 } + ) + } + .animation(.snappy(duration: 0.2), value: showDividerFAB) + .animation(.snappy(duration: 0.2), value: unseenMentionIDs.isEmpty) + .padding(.trailing, 16) + .padding(.bottom, 8) + } + .onChange(of: newMessagesDividerMessageID) { _, _ in + hasDismissedDividerFAB = false + } + } + } + } + + @ViewBuilder + private func messageBubble(for item: MessageDisplayItem, configuration: MessageBubbleConfiguration) -> some View { + if let message = viewModel.message(for: item) { + UnifiedMessageBubble( + message: message, + contactName: channel.name.isEmpty ? L10n.Chats.Chats.Channel.defaultName(Int(channel.index)) : channel.name, + deviceName: deviceName, + configuration: configuration, + displayState: MessageDisplayState( + showTimestamp: item.showTimestamp, + showDirectionGap: item.showDirectionGap, + showSenderName: item.showSenderName, + showNewMessagesDivider: item.showNewMessagesDivider, + detectedURL: item.detectedURL, + previewState: item.previewState, + loadedPreview: item.loadedPreview, + isImageURL: item.isImageURL, + decodedImage: viewModel.decodedImage(for: message.id), + decodedPreviewImage: viewModel.decodedPreviewImage(for: message.id), + decodedPreviewIcon: viewModel.decodedPreviewIcon(for: message.id), + isGIF: viewModel.isGIFImage(for: message.id), + showInlineImages: showInlineImages, + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + formattedText: viewModel.formattedText( + for: message.id, + text: message.text, + isOutgoing: message.isOutgoing, + currentUserName: deviceName, + isHighContrast: colorSchemeContrast == .increased + ) + ), + callbacks: MessageBubbleCallbacks( + onRetry: { onRetryMessage(message) }, + onReaction: { emoji in + recentEmojisStore.recordUsage(emoji) + Task { await viewModel.sendReaction(emoji: emoji, to: message) } + }, + onLongPress: { selectedMessageForActions = message }, + onImageTap: { + if let data = viewModel.imageData(for: message.id) { + imageViewerData = ImageViewerData( + imageData: data, + isGIF: false + ) + } + }, + onRetryImageFetch: { + Task { await viewModel.retryImageFetch(for: message.id) } + }, + onRequestPreviewFetch: { + if item.isImageURL && showInlineImages { + viewModel.requestImageFetch(for: message.id, showInlineImages: showInlineImages) + } else { + viewModel.requestPreviewFetch(for: message.id) + } + }, + onManualPreviewFetch: { + Task { + await viewModel.manualFetchPreview(for: message.id) + } + } + ) + ) + } else { + Text(L10n.Chats.Chats.Message.unavailable) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.Chats.Chats.Message.unavailableAccessibility) + } + } +} + +// MARK: - Channel Empty Messages View + +private struct ChannelEmptyMessagesView: View { + let channel: ChannelDTO + + var body: some View { + VStack(spacing: 16) { + ChannelAvatar(channel: channel, size: 80) + + Text(channel.name.isEmpty ? L10n.Chats.Chats.Channel.defaultName(Int(channel.index)) : channel.name) + .font(.title2) + .bold() + + Text(L10n.Chats.Chats.Channel.EmptyState.noMessages) + .foregroundStyle(.secondary) + + Text(channel.isPublicChannel || channel.name.hasPrefix("#") ? L10n.Chats.Chats.Channel.EmptyState.publicDescription : L10n.Chats.Chats.Channel.EmptyState.privateDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + #Preview { NavigationStack { ChannelChatView(channel: ChannelDTO(from: Channel( diff --git a/PocketMesh/Views/Chats/ChannelConversationRow.swift b/PocketMesh/Views/Chats/ChannelConversationRow.swift index 7381ad54..def85700 100644 --- a/PocketMesh/Views/Chats/ChannelConversationRow.swift +++ b/PocketMesh/Views/Chats/ChannelConversationRow.swift @@ -5,6 +5,7 @@ struct ChannelConversationRow: View { private typealias Strings = L10n.Chats.Chats.Row let channel: ChannelDTO let viewModel: ChatViewModel + var referenceDate: Date? var body: some View { HStack(spacing: 12) { @@ -28,7 +29,7 @@ struct ChannelConversationRow: View { } if let date = channel.lastMessageDate { - ConversationTimestamp(date: date) + ConversationTimestamp(date: date, referenceDate: referenceDate) } } diff --git a/PocketMesh/Views/Chats/ChannelInfoSheet.swift b/PocketMesh/Views/Chats/ChannelInfoSheet.swift index 7743490b..ba878d1a 100644 --- a/PocketMesh/Views/Chats/ChannelInfoSheet.swift +++ b/PocketMesh/Views/Chats/ChannelInfoSheet.swift @@ -78,6 +78,9 @@ struct ChannelInfoSheet: View { Section { Text(errorMessage) .foregroundStyle(.red) + Button(L10n.Localizable.Common.tryAgain) { + Task { await deleteChannel() } + } } } diff --git a/PocketMesh/Views/Chats/ChatView.swift b/PocketMesh/Views/Chats/ChatView.swift index 59f76945..b3fa9ad1 100644 --- a/PocketMesh/Views/Chats/ChatView.swift +++ b/PocketMesh/Views/Chats/ChatView.swift @@ -9,6 +9,7 @@ private let logger = Logger(subsystem: "com.pocketmesh", category: "ChatView") struct ChatView: View { @Environment(\.appState) private var appState @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.linkPreviewCache) private var linkPreviewCache @State private var contact: ContactDTO @@ -34,6 +35,8 @@ struct ChatView: View { @AppStorage("showInlineImages") private var showInlineImages = true @AppStorage("autoPlayGIFs") private var autoPlayGIFs = true + @AppStorage("showIncomingPath") private var showIncomingPath = false + @AppStorage("showIncomingHopCount") private var showIncomingHopCount = false init(contact: ContactDTO, parentViewModel: ChatViewModel? = nil) { self._contact = State(initialValue: contact) @@ -47,6 +50,8 @@ struct ChatView: View { deviceName: appState.connectedDevice?.nodeName ?? "Me", showInlineImages: showInlineImages, autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, isAtBottom: $isAtBottom, unreadCount: $unreadCount, scrollToBottomRequest: $scrollToBottomRequest, @@ -97,6 +102,7 @@ struct ChatView: View { handleMessageAction(action, for: message) } ) + .environment(\.horizontalSizeClass, horizontalSizeClass) } .fullScreenCover(item: $imageViewerData) { data in FullScreenImageViewer(data: data) @@ -351,7 +357,8 @@ struct ChatView: View { text: $viewModel.composingText, isFocused: $isInputFocused, placeholder: L10n.Chats.Chats.Input.Placeholder.directMessage, - maxBytes: ProtocolLimits.maxDirectMessageLength + maxBytes: ProtocolLimits.maxDirectMessageLength, + isEncrypted: true ) { text in scrollToBottomRequest += 1 Task { await viewModel.sendMessage(text: text) } @@ -411,6 +418,8 @@ private struct ChatMessagesContent: View { let deviceName: String let showInlineImages: Bool let autoPlayGIFs: Bool + let showIncomingPath: Bool + let showIncomingHopCount: Bool @Binding var isAtBottom: Bool @Binding var unreadCount: Int @Binding var scrollToBottomRequest: Int @@ -425,6 +434,7 @@ private struct ChatMessagesContent: View { let onMentionSeen: (UUID) async -> Void let onScrollToMention: () -> Void + @Environment(\.colorSchemeContrast) private var colorSchemeContrast @State private var hasDismissedDividerFAB = false private var showDividerFAB: Bool { @@ -519,13 +529,25 @@ private struct ChatMessagesContent: View { showDirectionGap: item.showDirectionGap, showSenderName: item.showSenderName, showNewMessagesDivider: item.showNewMessagesDivider, + detectedURL: item.detectedURL, previewState: item.previewState, loadedPreview: item.loadedPreview, isImageURL: item.isImageURL, decodedImage: viewModel.decodedImage(for: message.id), + decodedPreviewImage: viewModel.decodedPreviewImage(for: message.id), + decodedPreviewIcon: viewModel.decodedPreviewIcon(for: message.id), isGIF: viewModel.isGIFImage(for: message.id), showInlineImages: showInlineImages, - autoPlayGIFs: autoPlayGIFs + autoPlayGIFs: autoPlayGIFs, + showIncomingPath: showIncomingPath, + showIncomingHopCount: showIncomingHopCount, + formattedText: viewModel.formattedText( + for: message.id, + text: message.text, + isOutgoing: message.isOutgoing, + currentUserName: deviceName, + isHighContrast: colorSchemeContrast == .increased + ) ), callbacks: MessageBubbleCallbacks( onRetry: { diff --git a/PocketMesh/Views/Chats/ChatViewModel+Messages.swift b/PocketMesh/Views/Chats/ChatViewModel+Messages.swift index 927958c0..db4a7c8d 100644 --- a/PocketMesh/Views/Chats/ChatViewModel+Messages.swift +++ b/PocketMesh/Views/Chats/ChatViewModel+Messages.swift @@ -449,6 +449,8 @@ extension ChatViewModel { LinkPreviewService.extractFirstURL(from: text) }.value + cachedURLs[messageID] = detectedURL + guard let index = displayItemIndexByID[messageID] else { return } let item = displayItems[index] displayItems[index] = MessageDisplayItem( @@ -586,6 +588,10 @@ extension ChatViewModel { // Prepend older messages (they're chronologically earlier) messages.insert(contentsOf: olderMessages, at: 0) + // Re-run same-sender reordering across the page boundary to handle + // clusters that were split between the existing and newly loaded pages + messages = MessageDTO.reorderSameSenderClusters(messages) + // Update lookup dictionary for message in olderMessages { messagesByID[message.id] = message @@ -702,63 +708,67 @@ extension ChatViewModel { return nil } - /// Load last message previews for all conversations + /// Load last message previews for all conversations. + /// Uses batch fetch methods to minimize actor hops (2 hops instead of N). func loadLastMessagePreviews() async { guard let dataStore else { return } - // Load contact message previews - for contact in conversations { + // Batch fetch contact message previews (single actor hop) + if !conversations.isEmpty { do { - // Fetch extra messages in case recent ones are reactions - let messages = try await dataStore.fetchMessages(contactID: contact.id, limit: 10) - - // Find the last non-reaction message (skip outgoing reactions unless failed) - let lastMessage = messages.last { message in - guard message.direction == .outgoing, - ReactionParser.parseDM(message.text) != nil else { - return true + let contactMessages = try await dataStore.fetchLastMessages(contactIDs: conversations.map(\.id), limit: 10) + for contact in conversations { + guard let messages = contactMessages[contact.id] else { continue } + + // Find the last non-reaction message (skip outgoing reactions unless failed) + let lastMessage = messages.last { message in + guard message.direction == .outgoing, + ReactionParser.parseDM(message.text) != nil else { + return true + } + return message.status == .failed } - return message.status == .failed - } - if let lastMessage { - lastMessageCache[contact.id] = lastMessage + if let lastMessage { + lastMessageCache[contact.id] = lastMessage + } } } catch { - // Silently ignore errors for preview loading + logger.warning("Failed to load contact message previews: \(error)") } } - // Load channel message previews (filter out blocked senders) - let blockedNames = await syncCoordinator?.blockedSenderNames() ?? [] - for channel in channels { + // Batch fetch channel message previews (single actor hop) + if !channels.isEmpty { + let blockedNames = await syncCoordinator?.blockedSenderNames() ?? [] do { - // Fetch extra messages in case recent ones are from blocked senders - let messages = try await dataStore.fetchMessages(deviceID: channel.deviceID, channelIndex: channel.index, limit: 20) - - // Filter out messages from blocked senders and outgoing reactions - let lastMessage = messages.last { message in - // Skip blocked senders - if let senderName = message.senderNodeName, - blockedNames.contains(senderName) { - return false - } - // Skip outgoing reactions (unless failed) - if message.direction == .outgoing, - ReactionParser.parse(message.text) != nil, - message.status != .failed { - return false + let channelParams = channels.map { (deviceID: $0.deviceID, channelIndex: $0.index, id: $0.id) } + let channelMessages = try await dataStore.fetchLastChannelMessages(channels: channelParams, limit: 20) + for channel in channels { + guard let messages = channelMessages[channel.id] else { continue } + + // Filter out messages from blocked senders and outgoing reactions + let lastMessage = messages.last { message in + if let senderName = message.senderNodeName, + blockedNames.contains(senderName) { + return false + } + if message.direction == .outgoing, + ReactionParser.parse(message.text) != nil, + message.status != .failed { + return false + } + return true } - return true - } - if let lastMessage { - lastMessageCache[channel.id] = lastMessage - } else { - lastMessageCache.removeValue(forKey: channel.id) + if let lastMessage { + lastMessageCache[channel.id] = lastMessage + } else { + lastMessageCache.removeValue(forKey: channel.id) + } } } catch { - // Silently ignore errors for preview loading + logger.warning("Failed to load channel message previews: \(error)") } } } @@ -903,16 +913,29 @@ extension ChatViewModel { // MARK: - Display Items /// Build display items with pre-computed properties. + /// Uses cached URL results for previously processed messages and defers + /// async detection for new messages to avoid blocking the main actor. func buildDisplayItems() { messagesByID = Dictionary(uniqueKeysWithValues: messages.map { ($0.id, $0) }) - let urls = messages.map { LinkPreviewService.extractFirstURL(from: $0.text) } + var uncachedMessageIDs: [(UUID, String)] = [] displayItems = messages.enumerated().map { index, message in // Compute all display flags in single pass to avoid redundant array lookups let previous: MessageDTO? = index > 0 ? messages[index - 1] : nil let flags = Self.computeDisplayFlags(for: message, previous: previous) - let url = urls[index] + + // Use cached URL if available, otherwise nil (async detection below) + let url: URL? + if let cached = cachedURLs[message.id] { + url = cached + } else if previewStates[message.id] != nil || loadedPreviews[message.id] != nil { + // Message already had a preview fetched — URL was already detected + url = nil + } else { + url = nil + uncachedMessageIDs.append((message.id, message.text)) + } return MessageDisplayItem( messageID: message.id, @@ -937,6 +960,19 @@ extension ChatViewModel { // Build O(1) index lookup displayItemIndexByID = Dictionary(uniqueKeysWithValues: displayItems.enumerated().map { ($0.element.messageID, $0.offset) }) + + // Async URL detection for messages without cached results + if !uncachedMessageIDs.isEmpty { + let messagesToDetect = uncachedMessageIDs + Task { + for (messageID, text) in messagesToDetect { + await updateURLForDisplayItem(messageID: messageID, text: text) + } + } + } + + // Pre-decode legacy preview images off the main thread + decodeLegacyPreviewImages() } /// Get full message DTO for a display item. diff --git a/PocketMesh/Views/Chats/ChatViewModel+Previews.swift b/PocketMesh/Views/Chats/ChatViewModel+Previews.swift index 32e2f800..915ea94f 100644 --- a/PocketMesh/Views/Chats/ChatViewModel+Previews.swift +++ b/PocketMesh/Views/Chats/ChatViewModel+Previews.swift @@ -55,6 +55,7 @@ extension ChatViewModel { // Update state based on result switch result { case .loaded(let dto): + await decodeAndStorePreviewImages(from: dto, for: messageID) previewStates[messageID] = .loaded loadedPreviews[messageID] = dto // VoiceOver announcement for dynamic content @@ -92,6 +93,7 @@ extension ChatViewModel { switch result { case .loaded(let dto): + await decodeAndStorePreviewImages(from: dto, for: messageID) previewStates[messageID] = .loaded loadedPreviews[messageID] = dto // VoiceOver announcement for dynamic content @@ -108,6 +110,22 @@ extension ChatViewModel { rebuildDisplayItem(for: messageID) } + /// Decode preview hero image and icon off the main thread and store results + private func decodeAndStorePreviewImages(from dto: LinkPreviewDataDTO, for messageID: UUID) async { + async let heroResult: UIImage? = { + guard let data = dto.imageData else { return nil } + return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value + }() + async let iconResult: UIImage? = { + guard let data = dto.iconData else { return nil } + return await Task.detached { ImageURLDetector.downsampledImage(from: data) }.value + }() + let (hero, icon) = await (heroResult, iconResult) + if hero != nil || icon != nil { + decodedPreviewAssets[messageID] = DecodedPreviewAssets(image: hero, icon: icon) + } + } + /// Rebuild a single display item with current preview state (O(1) lookup) func rebuildDisplayItem(for messageID: UUID) { guard let index = displayItemIndexByID[messageID] else { return } @@ -147,6 +165,10 @@ extension ChatViewModel { previewFetchTasks.removeAll() previewStates.removeAll() loadedPreviews.removeAll() + decodedPreviewAssets.removeAll() + legacyPreviewDecodeInFlight.removeAll() + cachedURLs.removeAll() + formattedTexts.removeAll() clearImageState() } @@ -154,6 +176,8 @@ extension ChatViewModel { func cleanupPreviewState(for messageID: UUID) { previewStates.removeValue(forKey: messageID) loadedPreviews.removeValue(forKey: messageID) + decodedPreviewAssets.removeValue(forKey: messageID) + formattedTexts.removeValue(forKey: messageID) previewFetchTasks[messageID]?.cancel() previewFetchTasks.removeValue(forKey: messageID) cleanupImageState(for: messageID) @@ -166,6 +190,50 @@ extension ChatViewModel { decodedImages[messageID] } + /// Returns the pre-decoded link preview hero image for a message + func decodedPreviewImage(for messageID: UUID) -> UIImage? { + decodedPreviewAssets[messageID]?.image + } + + /// Returns the pre-decoded link preview icon for a message + func decodedPreviewIcon(for messageID: UUID) -> UIImage? { + decodedPreviewAssets[messageID]?.icon + } + + /// Pre-decode images for legacy messages with embedded preview data + func decodeLegacyPreviewImages() { + for message in messages where message.linkPreviewURL != nil { + let id = message.id + let existing = decodedPreviewAssets[id] + let needsImageDecode = message.linkPreviewImageData != nil && existing?.image == nil + let needsIconDecode = message.linkPreviewIconData != nil && existing?.icon == nil + guard needsImageDecode || needsIconDecode, + !legacyPreviewDecodeInFlight.contains(id) else { continue } + + let imageData = message.linkPreviewImageData + let iconData = message.linkPreviewIconData + + legacyPreviewDecodeInFlight.insert(id) + Task { + async let heroResult: UIImage? = if needsImageDecode, let imageData { + await Task.detached { ImageURLDetector.downsampledImage(from: imageData) }.value + } else { + existing?.image + } + async let iconResult: UIImage? = if needsIconDecode, let iconData { + await Task.detached { ImageURLDetector.downsampledImage(from: iconData) }.value + } else { + existing?.icon + } + let (hero, icon) = await (heroResult, iconResult) + if hero != nil || icon != nil { + self.decodedPreviewAssets[id] = DecodedPreviewAssets(image: hero, icon: icon) + } + self.legacyPreviewDecodeInFlight.remove(id) + } + } + } + /// Returns whether the image for a message is a GIF func isGIFImage(for messageID: UUID) -> Bool { imageIsGIF[messageID] ?? false diff --git a/PocketMesh/Views/Chats/ChatViewModel.swift b/PocketMesh/Views/Chats/ChatViewModel.swift index cd24c39c..10471f70 100644 --- a/PocketMesh/Views/Chats/ChatViewModel.swift +++ b/PocketMesh/Views/Chats/ChatViewModel.swift @@ -3,6 +3,12 @@ import UIKit import PocketMeshServices import OSLog +/// Decoded preview hero image and icon, stored together to batch Observable notifications +struct DecodedPreviewAssets { + var image: UIImage? + var icon: UIImage? +} + /// ViewModel for chat operations @Observable @MainActor @@ -170,6 +176,12 @@ final class ChatViewModel { /// Pre-decoded UIImage per message (avoids decoding in view body) var decodedImages: [UUID: UIImage] = [:] + /// Pre-decoded link preview assets (single dictionary to batch Observable notifications) + var decodedPreviewAssets: [UUID: DecodedPreviewAssets] = [:] + + /// Tracks in-flight legacy preview decode tasks to prevent duplicates + var legacyPreviewDecodeInFlight: Set = [] + /// Whether each image message is a GIF (computed once during decode) var imageIsGIF: [UUID: Bool] = [:] @@ -180,6 +192,31 @@ final class ChatViewModel { /// Key format: "{messageID}-{emoji}" var inFlightReactions: Set = [] + /// Cached URL detection results to avoid re-running NSDataDetector on rebuilds + var cachedURLs: [UUID: URL?] = [:] + + /// Cached formatted text per message (avoids rebuilding AttributedString on every render) + @ObservationIgnored var formattedTexts: [UUID: AttributedString] = [:] + + /// Returns cached formatted text for a message, building and caching on first access + func formattedText( + for messageID: UUID, + text: String, + isOutgoing: Bool, + currentUserName: String?, + isHighContrast: Bool + ) -> AttributedString { + if let cached = formattedTexts[messageID] { return cached } + let result = MessageText.buildFormattedText( + text: text, + isOutgoing: isOutgoing, + currentUserName: currentUserName, + isHighContrast: isHighContrast + ) + formattedTexts[messageID] = result + return result + } + // MARK: - Pagination State /// Whether currently fetching older messages (exposed for UI binding) @@ -285,8 +322,8 @@ final class ChatViewModel { return DisplayFlags(showTimestamp: true, showDirectionGap: false, showSenderName: true) } - // Time gap calculation (shared by timestamp and sender name logic) - let timeGap = abs(Int(message.timestamp) - Int(previous.timestamp)) + // Time gap calculation based on receive time (consistent with sort order) + let timeGap = abs(Int(message.createdAt.timeIntervalSince(previous.createdAt))) // Timestamp: gap > 5 minutes let showTimestamp = timeGap > messageGroupingGapSeconds diff --git a/PocketMesh/Views/Chats/ChatsView.swift b/PocketMesh/Views/Chats/ChatsView.swift index 6ae298e7..80a0435d 100644 --- a/PocketMesh/Views/Chats/ChatsView.swift +++ b/PocketMesh/Views/Chats/ChatsView.swift @@ -23,6 +23,8 @@ struct ChatsView: View { @State private var roomToAuthenticate: RemoteNodeSessionDTO? @State private var roomToDelete: RemoteNodeSessionDTO? @State private var showRoomDeleteAlert = false + @State private var showChannelDeleteFailed = false + @State private var channelDeleteFailure: ChannelDeleteFailure? @State private var pendingChatContact: ContactDTO? @State private var pendingChannel: ChannelDTO? @State private var hashtagToJoin: HashtagJoinRequest? @@ -183,6 +185,33 @@ struct ChatsView: View { } message: { Text(L10n.Chats.Chats.Alert.LeaveRoom.message) } + .alert( + L10n.Chats.Chats.ChannelInfo.DeleteFailed.title, + isPresented: $showChannelDeleteFailed, + presenting: channelDeleteFailure + ) { failure in + Button(L10n.Localizable.Common.tryAgain) { + deleteChannelConversation(failure.channel) + } + Button(L10n.Chats.Chats.Common.ok, role: .cancel) { } + } message: { failure in + Text(failure.message) + } + } + + private struct ChannelDeleteFailure { + let channel: ChannelDTO + let message: String + } + + private enum ChannelDeleteError: LocalizedError { + case servicesUnavailable + + var errorDescription: String? { + switch self { + case .servicesUnavailable: L10n.Chats.Chats.Error.servicesUnavailable + } + } } private func loadConversations() async { @@ -252,7 +281,6 @@ struct ChatsView: View { deleteDirectConversation(contact) case .channel(let channel): - routeBeingDeleted = .channel(channel) deleteChannelConversation(channel) case .room(let session): @@ -262,19 +290,9 @@ struct ChatsView: View { } private func deleteDirectConversation(_ contact: ContactDTO) { - if shouldUseSplitView && appState.navigation.chatsSelectedRoute == .direct(contact) { - selectedRoute = nil - appState.navigation.chatsSelectedRoute = nil - } - + clearNavigationIfActive(.direct(contact)) viewModel.removeConversation(.direct(contact)) - if !shouldUseSplitView && activeRoute == .direct(contact) { - navigationPath.removeLast(navigationPath.count) - activeRoute = nil - appState.navigation.tabBarVisibility = .visible - } - Task { try? await viewModel.deleteConversation(for: contact) await loadConversations() @@ -283,23 +301,18 @@ struct ChatsView: View { } private func deleteChannelConversation(_ channel: ChannelDTO) { - if shouldUseSplitView && appState.navigation.chatsSelectedRoute == .channel(channel) { - selectedRoute = nil - appState.navigation.chatsSelectedRoute = nil - } - - viewModel.removeConversation(.channel(channel)) - - if !shouldUseSplitView && activeRoute == .channel(channel) { - navigationPath.removeLast(navigationPath.count) - activeRoute = nil - appState.navigation.tabBarVisibility = .visible - } - Task { - await deleteChannel(channel) - await loadConversations() - routeBeingDeleted = nil + do { + try await deleteChannel(channel) + clearNavigationIfActive(.channel(channel)) + await loadConversations() + } catch { + channelDeleteFailure = ChannelDeleteFailure( + channel: channel, + message: error.localizedDescription + ) + showChannelDeleteFailed = true + } } } @@ -318,40 +331,38 @@ struct ChatsView: View { await appState.services?.notificationService.updateBadgeCount() await MainActor.run { - if shouldUseSplitView && appState.navigation.chatsSelectedRoute == .room(session) { - selectedRoute = nil - appState.navigation.chatsSelectedRoute = nil - } - + clearNavigationIfActive(.room(session)) viewModel.removeConversation(.room(session)) - - if !shouldUseSplitView && activeRoute == .room(session) { - navigationPath.removeLast(navigationPath.count) - activeRoute = nil - appState.navigation.tabBarVisibility = .visible - } } } catch { chatsViewLogger.error("Failed to delete room: \(error)") } } - private func deleteChannel(_ channel: ChannelDTO) async { - guard let channelService = appState.services?.channelService else { return } + private func deleteChannel(_ channel: ChannelDTO) async throws { + guard let channelService = appState.services?.channelService else { + throw ChannelDeleteError.servicesUnavailable + } + try await channelService.clearChannel( + deviceID: channel.deviceID, + index: channel.index + ) + await appState.services?.notificationService.removeDeliveredNotifications( + forChannelIndex: channel.index, + deviceID: channel.deviceID + ) + await appState.services?.notificationService.updateBadgeCount() + } - do { - try await channelService.clearChannel( - deviceID: channel.deviceID, - index: channel.index - ) - await appState.services?.notificationService.removeDeliveredNotifications( - forChannelIndex: channel.index, - deviceID: channel.deviceID - ) - await appState.services?.notificationService.updateBadgeCount() - } catch { - chatsViewLogger.error("Failed to delete channel: \(error)") - await loadConversations() + private func clearNavigationIfActive(_ route: ChatRoute) { + if shouldUseSplitView && appState.navigation.chatsSelectedRoute == route { + selectedRoute = nil + appState.navigation.chatsSelectedRoute = nil + } + if !shouldUseSplitView && activeRoute == route { + navigationPath.removeLast(navigationPath.count) + activeRoute = nil + appState.navigation.tabBarVisibility = .visible } } diff --git a/PocketMesh/Views/Chats/Components/ChatInputBar.swift b/PocketMesh/Views/Chats/Components/ChatInputBar.swift index 4be65866..6de9ed0a 100644 --- a/PocketMesh/Views/Chats/Components/ChatInputBar.swift +++ b/PocketMesh/Views/Chats/Components/ChatInputBar.swift @@ -8,6 +8,7 @@ struct ChatInputBar: View { @FocusState.Binding var isFocused: Bool let placeholder: String let maxBytes: Int + let isEncrypted: Bool let onSend: (String) -> Void @State private var isCoolingDown = false @@ -27,7 +28,7 @@ struct ChatInputBar: View { var body: some View { HStack(alignment: .bottom, spacing: 12) { - ChatInputTextField(text: $text, placeholder: placeholder, isFocused: $isFocused) + ChatInputTextField(text: $text, placeholder: placeholder, isFocused: $isFocused, isEncrypted: isEncrypted) ChatSendButtonWithCounter( canSend: canSend, isOverLimit: isOverLimit, @@ -89,17 +90,27 @@ private struct ChatInputTextField: View { @Binding var text: String let placeholder: String @FocusState.Binding var isFocused: Bool + let isEncrypted: Bool var body: some View { TextField(placeholder, text: $text, axis: .vertical) .textFieldStyle(.plain) - .padding(.horizontal, 12) + .padding(.leading, 12) + .padding(.trailing, 28) .padding(.vertical, 8) + .overlay(alignment: .trailing) { + Image(systemName: isEncrypted ? "lock.fill" : "lock.open.fill") + .font(.footnote) + .foregroundStyle(isEncrypted ? .blue : .orange) + .padding(.trailing, 10) + .accessibilityHidden(true) + } .textFieldBackground() .lineLimit(1...5) .focused($isFocused) .accessibilityLabel(L10n.Chats.Chats.Input.accessibilityLabel) .accessibilityHint(L10n.Chats.Chats.Input.accessibilityHint) + .accessibilityValue(isEncrypted ? L10n.Chats.Chats.Input.encrypted : L10n.Chats.Chats.Input.notEncrypted) } } diff --git a/PocketMesh/Views/Chats/Components/LinkPreviewCard.swift b/PocketMesh/Views/Chats/Components/LinkPreviewCard.swift index f49e1eb9..b6bd6dab 100644 --- a/PocketMesh/Views/Chats/Components/LinkPreviewCard.swift +++ b/PocketMesh/Views/Chats/Components/LinkPreviewCard.swift @@ -5,8 +5,8 @@ import PocketMeshServices struct LinkPreviewCard: View { let url: URL let title: String? - let imageData: Data? - let iconData: Data? + let image: UIImage? + let icon: UIImage? let onTap: () -> Void @Environment(\.dynamicTypeSize) private var dynamicTypeSize @@ -28,8 +28,8 @@ struct LinkPreviewCard: View { Button(action: onTap) { VStack(alignment: .leading, spacing: 0) { // Hero image (if available) - if let imageData, let uiImage = UIImage(data: imageData) { - Image(uiImage: uiImage) + if let image { + Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fill) .frame(maxHeight: 150) @@ -39,8 +39,8 @@ struct LinkPreviewCard: View { // Title and domain HStack(spacing: 8) { // Icon or globe fallback - if let iconData, let uiImage = UIImage(data: iconData) { - Image(uiImage: uiImage) + if let icon { + Image(uiImage: icon) .resizable() .frame(width: 16, height: 16) .clipShape(.rect(cornerRadius: 4)) @@ -82,8 +82,8 @@ struct LinkPreviewCard: View { LinkPreviewCard( url: URL(string: "https://apple.com/iphone")!, title: "iPhone 16 Pro - Apple", - imageData: nil, - iconData: nil, + image: nil, + icon: nil, onTap: {} ) .padding() @@ -93,8 +93,8 @@ struct LinkPreviewCard: View { LinkPreviewCard( url: URL(string: "https://example.com/article")!, title: "An Interesting Article About Technology", - imageData: nil, - iconData: nil, + image: nil, + icon: nil, onTap: {} ) .padding() diff --git a/PocketMesh/Views/Chats/Components/MessageText.swift b/PocketMesh/Views/Chats/Components/MessageText.swift index 461f1cb3..e0ceb8b8 100644 --- a/PocketMesh/Views/Chats/Components/MessageText.swift +++ b/PocketMesh/Views/Chats/Components/MessageText.swift @@ -5,18 +5,28 @@ import PocketMeshServices struct MessageText: View { let text: String let baseColor: Color + let isOutgoing: Bool let currentUserName: String? + let precomputedText: AttributedString? @Environment(\.colorSchemeContrast) private var colorSchemeContrast - init(_ text: String, baseColor: Color = .primary, currentUserName: String? = nil) { + init( + _ text: String, + baseColor: Color = .primary, + isOutgoing: Bool = false, + currentUserName: String? = nil, + precomputedText: AttributedString? = nil + ) { self.text = text self.baseColor = baseColor + self.isOutgoing = isOutgoing self.currentUserName = currentUserName + self.precomputedText = precomputedText } var body: some View { - Text(formattedText) + Text(precomputedText ?? formattedText) } /// Exposes formatted text for testing @@ -25,27 +35,53 @@ struct MessageText: View { } private var formattedText: AttributedString { + Self.buildFormattedText( + text: text, + isOutgoing: isOutgoing, + currentUserName: currentUserName, + isHighContrast: colorSchemeContrast == .increased + ) + } + + /// Builds an AttributedString with mention, URL, and hashtag formatting. + /// Static so it can be called from both the view and the ViewModel cache. + static func buildFormattedText( + text: String, + isOutgoing: Bool, + currentUserName: String?, + isHighContrast: Bool + ) -> AttributedString { + let baseColor: Color = isOutgoing ? .white : .primary var result = AttributedString(text) result.foregroundColor = baseColor - // Apply mention formatting (@[name] -> bold @name) - applyMentionFormatting(&result) + applyMentionFormatting( + &result, + text: text, + baseColor: baseColor, + isOutgoing: isOutgoing, + currentUserName: currentUserName, + isHighContrast: isHighContrast + ) - // Apply URL formatting (make links tappable) - applyURLFormatting(&result) + let (urlRanges, currentString) = applyURLFormatting(&result, baseColor: baseColor) - // Apply hashtag formatting (make #channels tappable) - applyHashtagFormatting(&result) + applyHashtagFormatting(&result, isOutgoing: isOutgoing, urlRanges: urlRanges, currentString: currentString) return result } // MARK: - Mention Formatting - private func applyMentionFormatting(_ attributedString: inout AttributedString) { - let pattern = MentionUtilities.mentionPattern - - guard let regex = try? NSRegularExpression(pattern: pattern) else { return } + private static func applyMentionFormatting( + _ attributedString: inout AttributedString, + text: String, + baseColor: Color, + isOutgoing: Bool, + currentUserName: String?, + isHighContrast: Bool + ) { + guard let regex = MentionUtilities.mentionRegex else { return } let nsRange = NSRange(text.startIndex..., in: text) let matches = regex.matches(in: text, range: nsRange) @@ -64,14 +100,11 @@ struct MessageText: View { name.localizedCaseInsensitiveCompare($0) == .orderedSame } ?? false - // Determine if we're on a dark bubble (outgoing messages use white text) - let isOnDarkBubble = baseColor == .white - // Replace @[name] with @name, styled appropriately for bubble color var replacement = AttributedString("@\(name)") replacement.underlineStyle = .single - if isOnDarkBubble { + if isOutgoing { // On dark bubbles: use white text, with background only for self-mentions replacement.foregroundColor = .white if isSelfMention { @@ -81,7 +114,7 @@ struct MessageText: View { // On light bubbles: use sender color for the mentioned name let mentionColor = AppColors.NameColor.color( for: name, - highContrast: colorSchemeContrast == .increased + highContrast: isHighContrast ) replacement.foregroundColor = mentionColor if isSelfMention { @@ -99,8 +132,12 @@ struct MessageText: View { try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) }() - private func applyURLFormatting(_ attributedString: inout AttributedString) { - guard let detector = Self.urlDetector else { return } + /// Applies URL formatting and returns the detected URL ranges + current string for reuse + private static func applyURLFormatting( + _ attributedString: inout AttributedString, + baseColor: Color + ) -> (urlRanges: [Range], currentString: String) { + guard let detector = urlDetector else { return ([], "") } // Collect ranges already styled as mentions (have underline style) // URLs within these ranges should not be converted to links @@ -116,6 +153,8 @@ struct MessageText: View { let nsRange = NSRange(currentString.startIndex..., in: currentString) let matches = detector.matches(in: currentString, options: [], range: nsRange) + var urlRanges: [Range] = [] + // Process matches in reverse to preserve indices for match in matches.reversed() { guard let url = match.url, @@ -124,6 +163,8 @@ struct MessageText: View { let matchRange = Range(match.range, in: currentString), let attrRange = Range(matchRange, in: attributedString) else { continue } + urlRanges.append(matchRange) + // Skip URLs that overlap with mention ranges let overlapsWithMention = mentionRanges.contains { mentionRange in attrRange.overlaps(mentionRange) @@ -136,13 +177,19 @@ struct MessageText: View { attributedString[attrRange].foregroundColor = baseColor attributedString[attrRange].underlineStyle = .single } + + return (urlRanges, currentString) } // MARK: - Hashtag Formatting - private func applyHashtagFormatting(_ attributedString: inout AttributedString) { - let currentString = String(attributedString.characters) - let hashtags = HashtagUtilities.extractHashtags(from: currentString) + private static func applyHashtagFormatting( + _ attributedString: inout AttributedString, + isOutgoing: Bool, + urlRanges: [Range], + currentString: String + ) { + let hashtags = HashtagUtilities.extractHashtags(from: currentString, urlRanges: urlRanges) // Process in reverse to preserve indices for hashtag in hashtags.reversed() { @@ -155,8 +202,7 @@ struct MessageText: View { attributedString[attrRange].link = url // Hashtags: bold + cyan (or white on dark bubbles), no underline // This distinguishes them from URLs which remain underlined - let isOnDarkBubble = baseColor == .white - attributedString[attrRange].foregroundColor = isOnDarkBubble ? .white : .cyan + attributedString[attrRange].foregroundColor = isOutgoing ? .white : .cyan attributedString[attrRange].inlinePresentationIntent = .stronglyEmphasized } } @@ -189,19 +235,19 @@ struct MessageText: View { } #Preview("Outgoing message") { - MessageText("Visit https://github.com", baseColor: .white) + MessageText("Visit https://github.com", baseColor: .white, isOutgoing: true) .padding() .background(.blue) } #Preview("Outgoing with mention") { - MessageText("Hey @[Alice], check this out!", baseColor: .white) + MessageText("Hey @[Alice], check this out!", baseColor: .white, isOutgoing: true) .padding() .background(.blue) } #Preview("Outgoing with self-mention") { - MessageText("@[MyDevice] check this!", baseColor: .white, currentUserName: "MyDevice") + MessageText("@[MyDevice] check this!", baseColor: .white, isOutgoing: true, currentUserName: "MyDevice") .padding() .background(.blue) } diff --git a/PocketMesh/Views/Chats/Components/UnifiedMessageBubble.swift b/PocketMesh/Views/Chats/Components/UnifiedMessageBubble.swift index a364a704..688328eb 100644 --- a/PocketMesh/Views/Chats/Components/UnifiedMessageBubble.swift +++ b/PocketMesh/Views/Chats/Components/UnifiedMessageBubble.swift @@ -60,13 +60,19 @@ struct MessageDisplayState { var showDirectionGap: Bool = false var showSenderName: Bool = true var showNewMessagesDivider: Bool = false + var detectedURL: URL? var previewState: PreviewLoadState = .idle var loadedPreview: LinkPreviewDataDTO? var isImageURL: Bool = false var decodedImage: UIImage? + var decodedPreviewImage: UIImage? + var decodedPreviewIcon: UIImage? var isGIF: Bool = false var showInlineImages: Bool = false var autoPlayGIFs: Bool = true + var showIncomingPath: Bool = false + var showIncomingHopCount: Bool = false + var formattedText: AttributedString? } /// Callbacks for message bubble interactions @@ -112,7 +118,7 @@ struct UnifiedMessageBubble: View { } var body: some View { - VStack(spacing: 2) { + VStack(spacing: 0) { if displayState.showNewMessagesDivider { NewMessagesDividerView() .padding(.bottom, 4) @@ -129,7 +135,7 @@ struct UnifiedMessageBubble: View { Spacer(minLength: 40) } - VStack(alignment: message.isOutgoing ? .trailing : .leading, spacing: 2) { + VStack(alignment: message.isOutgoing ? .trailing : .leading, spacing: 0) { // Sender name for incoming channel messages (hidden for continuation messages in a group) if !message.isOutgoing && configuration.showSenderName && displayState.showSenderName { Text(senderName) @@ -168,7 +174,7 @@ struct UnifiedMessageBubble: View { // Malware warning (always shown, regardless of preview settings) if displayState.previewState == .malwareWarning, - let url = LinkPreviewService.extractFirstURL(from: message.text) { + let url = displayState.detectedURL { MalwareWarningCard(url: url) } @@ -198,12 +204,12 @@ struct UnifiedMessageBubble: View { } } .padding(.horizontal, 12) - .padding(.top, displayState.showDirectionGap ? 6 : (displayState.showSenderName ? 4 : 2)) - .padding(.bottom, 2) + .padding(.top, displayState.showDirectionGap ? 6 : (displayState.showSenderName ? 4 : 1)) + .padding(.bottom, 0) .onAppear { // Request preview/image fetch when cell becomes visible // ViewModel handles deduplication and cancellation - if displayState.previewState == .idle && detectedURL != nil && message.linkPreviewURL == nil { + if displayState.previewState == .idle && displayState.detectedURL != nil && message.linkPreviewURL == nil { callbacks.onRequestPreviewFetch?() } } @@ -222,10 +228,6 @@ struct UnifiedMessageBubble: View { AppColors.NameColor.color(for: senderName, highContrast: colorSchemeContrast == .increased) } - private var detectedURL: URL? { - LinkPreviewService.extractFirstURL(from: message.text) - } - private var accessibilityMessageLabel: String { var label = "" // Always include sender name for screen readers, even when visually hidden @@ -251,9 +253,6 @@ private struct BubbleContent: View { let displayState: MessageDisplayState let callbacks: MessageBubbleCallbacks - @AppStorage("showIncomingPath") private var showIncomingPath = false - @AppStorage("showIncomingHopCount") private var showIncomingHopCount = false - private var textColor: Color { message.isOutgoing ? .white : .primary } @@ -273,14 +272,14 @@ private struct BubbleContent: View { var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 4) { - MessageText(message.text, baseColor: textColor, currentUserName: deviceName) + MessageText(message.text, baseColor: textColor, isOutgoing: message.isOutgoing, currentUserName: deviceName, precomputedText: displayState.formattedText) - if !message.isOutgoing && (showIncomingHopCount && !isDirect || showIncomingPath) { + if !message.isOutgoing && (displayState.showIncomingHopCount && !isDirect || displayState.showIncomingPath) { HStack(spacing: 4) { - if showIncomingHopCount && !isDirect { + if displayState.showIncomingHopCount && !isDirect { BubbleHopCountFooter(pathLength: message.pathLength) } - if showIncomingPath { + if displayState.showIncomingPath { BubblePathFooter(message: message) } } @@ -307,10 +306,6 @@ private struct BubbleEmbeddedImageContent: View { let displayState: MessageDisplayState let callbacks: MessageBubbleCallbacks - private var detectedURL: URL? { - LinkPreviewService.extractFirstURL(from: message.text) - } - var body: some View { switch displayState.previewState { case .loaded: @@ -326,7 +321,7 @@ private struct BubbleEmbeddedImageContent: View { } case .loading, .idle: - if detectedURL != nil { + if displayState.detectedURL != nil { HStack(spacing: 8) { ProgressView() .controlSize(.small) @@ -369,10 +364,6 @@ private struct BubbleLinkPreviewContent: View { @Environment(\.openURL) private var openURL - private var detectedURL: URL? { - LinkPreviewService.extractFirstURL(from: message.text) - } - var body: some View { switch displayState.previewState { case .loaded: @@ -381,14 +372,14 @@ private struct BubbleLinkPreviewContent: View { LinkPreviewCard( url: url, title: preview.title, - imageData: preview.imageData, - iconData: preview.iconData, + image: displayState.decodedPreviewImage, + icon: displayState.decodedPreviewIcon, onTap: { openURL(url) } ) } case .loading: - if let url = detectedURL { + if let url = displayState.detectedURL { LinkPreviewLoadingCard(url: url) } @@ -396,7 +387,7 @@ private struct BubbleLinkPreviewContent: View { EmptyView() case .disabled: - if let url = detectedURL { + if let url = displayState.detectedURL { TapToLoadPreview( url: url, isLoading: false, @@ -413,11 +404,11 @@ private struct BubbleLinkPreviewContent: View { LinkPreviewCard( url: url, title: message.linkPreviewTitle, - imageData: message.linkPreviewImageData, - iconData: message.linkPreviewIconData, + image: displayState.decodedPreviewImage, + icon: displayState.decodedPreviewIcon, onTap: { openURL(url) } ) - } else if let url = detectedURL { + } else if let url = displayState.detectedURL { // URL detected, waiting for fetch - show loading LinkPreviewLoadingCard(url: url) } diff --git a/PocketMesh/Views/Chats/ConversationListContent.swift b/PocketMesh/Views/Chats/ConversationListContent.swift index 9289d15f..48655730 100644 --- a/PocketMesh/Views/Chats/ConversationListContent.swift +++ b/PocketMesh/Views/Chats/ConversationListContent.swift @@ -57,74 +57,6 @@ struct ConversationListContent: View { self.onDeleteConversation = onDeleteConversation } - @ViewBuilder - private func conversationRow(for conversation: Conversation) -> some View { - let route = ChatRoute(conversation: conversation) - switch conversation { - case .direct(let contact): - ConversationRow(contact: contact, viewModel: viewModel) - .tag(route) - .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { - onDeleteConversation(conversation) - } - - case .channel(let channel): - ChannelConversationRow(channel: channel, viewModel: viewModel) - .tag(route) - .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { - onDeleteConversation(conversation) - } - - case .room(let session): - RoomConversationRow(session: session) - .tag(route) - .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { - onDeleteConversation(conversation) - } - } - } - - @ViewBuilder - private func navigationRow( - for conversation: Conversation, - onNavigate: @escaping (ChatRoute) -> Void, - onRequestRoomAuth: @escaping (RemoteNodeSessionDTO) -> Void - ) -> some View { - let route = ChatRoute(conversation: conversation) - switch conversation { - case .direct(let contact): - NavigationLink(value: route) { - ConversationRow(contact: contact, viewModel: viewModel) - } - .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { - onDeleteConversation(conversation) - } - - case .channel(let channel): - NavigationLink(value: route) { - ChannelConversationRow(channel: channel, viewModel: viewModel) - } - .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { - onDeleteConversation(conversation) - } - - case .room(let session): - Button { - if session.isConnected { - onNavigate(route) - } else { - onRequestRoomAuth(session) - } - } label: { - RoomConversationRow(session: session) - } - .buttonStyle(.plain) - .conversationSwipeActions(conversation: conversation, viewModel: viewModel) { - onDeleteConversation(conversation) - } - } - } - private var pickerSection: some View { Section { ChatFilterPicker(selection: $selectedFilter) @@ -139,27 +71,29 @@ struct ConversationListContent: View { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - listContent - .overlay { - if favoriteConversations.isEmpty && otherConversations.isEmpty { - ContentUnavailableView { - Label(emptyStateMessage.title, systemImage: emptyStateMessage.systemImage) - } description: { - Text(emptyStateMessage.description) - } actions: { - if selectedFilter != .all { - Button(L10n.Chats.Chats.Filter.clear) { - selectedFilter = .all + TimelineView(.everyMinute) { context in + listContent(referenceDate: context.date) + .overlay { + if favoriteConversations.isEmpty && otherConversations.isEmpty { + ContentUnavailableView { + Label(emptyStateMessage.title, systemImage: emptyStateMessage.systemImage) + } description: { + Text(emptyStateMessage.description) + } actions: { + if selectedFilter != .all { + Button(L10n.Chats.Chats.Filter.clear) { + selectedFilter = .all + } } } } } - } + } } } @ViewBuilder - private var listContent: some View { + private func listContent(referenceDate: Date) -> some View { switch mode { case .selection(let selection): List(selection: selection) { @@ -167,7 +101,12 @@ struct ConversationListContent: View { Section { ForEach(favoriteConversations) { conversation in - conversationRow(for: conversation) + ConversationSelectionRow( + conversation: conversation, + viewModel: viewModel, + referenceDate: referenceDate, + onDelete: { onDeleteConversation(conversation) } + ) } } .accessibilityLabel(L10n.Chats.Chats.Section.favorites) @@ -175,7 +114,12 @@ struct ConversationListContent: View { Section { ForEach(otherConversations) { conversation in - conversationRow(for: conversation) + ConversationSelectionRow( + conversation: conversation, + viewModel: viewModel, + referenceDate: referenceDate, + onDelete: { onDeleteConversation(conversation) } + ) } } .accessibilityLabel(L10n.Chats.Chats.Section.conversations) @@ -189,7 +133,14 @@ struct ConversationListContent: View { Section { ForEach(favoriteConversations) { conversation in - navigationRow(for: conversation, onNavigate: onNavigate, onRequestRoomAuth: onRequestRoomAuth) + ConversationNavigationRow( + conversation: conversation, + viewModel: viewModel, + referenceDate: referenceDate, + onNavigate: onNavigate, + onRequestRoomAuth: onRequestRoomAuth, + onDelete: { onDeleteConversation(conversation) } + ) } } .accessibilityLabel(L10n.Chats.Chats.Section.favorites) @@ -197,7 +148,14 @@ struct ConversationListContent: View { Section { ForEach(otherConversations) { conversation in - navigationRow(for: conversation, onNavigate: onNavigate, onRequestRoomAuth: onRequestRoomAuth) + ConversationNavigationRow( + conversation: conversation, + viewModel: viewModel, + referenceDate: referenceDate, + onNavigate: onNavigate, + onRequestRoomAuth: onRequestRoomAuth, + onDelete: { onDeleteConversation(conversation) } + ) } } .accessibilityLabel(L10n.Chats.Chats.Section.conversations) @@ -207,3 +165,71 @@ struct ConversationListContent: View { } } } + +// MARK: - Extracted Row Views + +private struct ConversationSelectionRow: View { + let conversation: Conversation + let viewModel: ChatViewModel + let referenceDate: Date + let onDelete: () -> Void + + var body: some View { + let route = ChatRoute(conversation: conversation) + switch conversation { + case .direct(let contact): + ConversationRow(contact: contact, viewModel: viewModel, referenceDate: referenceDate) + .tag(route) + .conversationSwipeActions(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + + case .channel(let channel): + ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: referenceDate) + .tag(route) + .conversationSwipeActions(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + + case .room(let session): + RoomConversationRow(session: session, referenceDate: referenceDate) + .tag(route) + .conversationSwipeActions(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + } + } +} + +private struct ConversationNavigationRow: View { + let conversation: Conversation + let viewModel: ChatViewModel + let referenceDate: Date + let onNavigate: (ChatRoute) -> Void + let onRequestRoomAuth: (RemoteNodeSessionDTO) -> Void + let onDelete: () -> Void + + var body: some View { + let route = ChatRoute(conversation: conversation) + switch conversation { + case .direct(let contact): + NavigationLink(value: route) { + ConversationRow(contact: contact, viewModel: viewModel, referenceDate: referenceDate) + } + .conversationSwipeActions(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + + case .channel(let channel): + NavigationLink(value: route) { + ChannelConversationRow(channel: channel, viewModel: viewModel, referenceDate: referenceDate) + } + .conversationSwipeActions(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + + case .room(let session): + Button { + if session.isConnected { + onNavigate(route) + } else { + onRequestRoomAuth(session) + } + } label: { + RoomConversationRow(session: session, referenceDate: referenceDate) + } + .buttonStyle(.plain) + .conversationSwipeActions(conversation: conversation, viewModel: viewModel, onDelete: onDelete) + } + } +} diff --git a/PocketMesh/Views/Chats/ConversationRow.swift b/PocketMesh/Views/Chats/ConversationRow.swift index c8071e91..bcdd0950 100644 --- a/PocketMesh/Views/Chats/ConversationRow.swift +++ b/PocketMesh/Views/Chats/ConversationRow.swift @@ -4,6 +4,7 @@ import PocketMeshServices struct ConversationRow: View { let contact: ContactDTO let viewModel: ChatViewModel + var referenceDate: Date? var body: some View { HStack(spacing: 12) { @@ -30,7 +31,7 @@ struct ConversationRow: View { } if let date = contact.lastMessageDate { - ConversationTimestamp(date: date) + ConversationTimestamp(date: date, referenceDate: referenceDate) } } diff --git a/PocketMesh/Views/Chats/ConversationTimestamp.swift b/PocketMesh/Views/Chats/ConversationTimestamp.swift index 50591430..dfc0c255 100644 --- a/PocketMesh/Views/Chats/ConversationTimestamp.swift +++ b/PocketMesh/Views/Chats/ConversationTimestamp.swift @@ -3,21 +3,29 @@ import SwiftUI struct ConversationTimestamp: View { let date: Date var font: Font = .caption + var referenceDate: Date? var body: some View { - TimelineView(.everyMinute) { context in - Text(formattedDate(relativeTo: context.date)) + if let referenceDate { + Text(formattedDate(relativeTo: referenceDate)) .font(font) .foregroundStyle(.secondary) + } else { + TimelineView(.everyMinute) { context in + Text(formattedDate(relativeTo: context.date)) + .font(font) + .foregroundStyle(.secondary) + } } } private func formattedDate(relativeTo now: Date) -> String { let calendar = Calendar.current - if calendar.isDateInToday(date) { + if calendar.isDate(date, inSameDayAs: now) { return date.formatted(date: .omitted, time: .shortened) - } else if calendar.isDateInYesterday(date) { + } else if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), + calendar.isDate(date, inSameDayAs: yesterday) { return date.formatted(.relative(presentation: .named)) } else { return date.formatted(.dateTime.month(.abbreviated).day()) diff --git a/PocketMesh/Views/Chats/Reactions/MessageActionsSheet.swift b/PocketMesh/Views/Chats/Reactions/MessageActionsSheet.swift index 862c0098..e5172f00 100644 --- a/PocketMesh/Views/Chats/Reactions/MessageActionsSheet.swift +++ b/PocketMesh/Views/Chats/Reactions/MessageActionsSheet.swift @@ -17,6 +17,7 @@ struct MessageActionsSheet: View { @Environment(\.appState) private var appState @Environment(\.dismiss) private var dismiss @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.horizontalSizeClass) private var horizontalSizeClass let message: MessageDTO let senderName: String let recentEmojis: [String] @@ -103,7 +104,7 @@ struct MessageActionsSheet: View { } } .presentationDetents( - (UIDevice.current.userInterfaceIdiom == .pad || dynamicTypeSize.isAccessibilitySize) + (horizontalSizeClass == .regular || dynamicTypeSize.isAccessibilitySize) ? [.large] : [.medium, .large] ) .presentationContentInteraction(.scrolls) @@ -192,8 +193,7 @@ private struct ActionsTimestampLabel: View { let message: MessageDTO var body: some View { - Text(message.isOutgoing ? message.date : message.createdAt, - format: .dateTime.hour().minute()) + Text(message.date, format: .dateTime.hour().minute()) .font(.subheadline) .foregroundStyle(.secondary) } @@ -413,7 +413,7 @@ private struct ActionsOutgoingDetailsRows: View { var body: some View { ActionInfoRow(text: L10n.Chats.Chats.Message.Info.sent( - message.date.formatted(date: .abbreviated, time: .shortened))) + message.senderDate.formatted(date: .abbreviated, time: .shortened))) if let rtt = message.roundTripTime { ActionInfoRow(text: L10n.Chats.Chats.Message.Info.roundTrip(Int(rtt))) @@ -438,7 +438,7 @@ private struct ActionsIncomingDetailsRows: View { ) let sentText = L10n.Chats.Chats.Message.Info.sent( - message.date.formatted(date: .abbreviated, time: .shortened)) + message.senderDate.formatted(date: .abbreviated, time: .shortened)) let adjusted = message.timestampCorrected ? " " + L10n.Chats.Chats.Message.Info.adjusted : "" ActionInfoRow(text: sentText + adjusted) @@ -532,6 +532,7 @@ private struct ActionInfoRow: View { message: MessageDTO(from: message), senderName: "My Device", recentEmojis: RecentEmojisStore.defaultEmojis, + onAction: { print("Action: \($0)") } ) } @@ -551,6 +552,7 @@ private struct ActionInfoRow: View { message: MessageDTO(from: message), senderName: "Alice", recentEmojis: RecentEmojisStore.defaultEmojis, + onAction: { print("Action: \($0)") } ) } diff --git a/PocketMesh/Views/Chats/RoomConversationRow.swift b/PocketMesh/Views/Chats/RoomConversationRow.swift index 27a3ffa8..eb701ebc 100644 --- a/PocketMesh/Views/Chats/RoomConversationRow.swift +++ b/PocketMesh/Views/Chats/RoomConversationRow.swift @@ -4,6 +4,7 @@ import PocketMeshServices struct RoomConversationRow: View { @Environment(\.appState) private var appState let session: RemoteNodeSessionDTO + var referenceDate: Date? var body: some View { HStack(spacing: 12) { @@ -27,7 +28,7 @@ struct RoomConversationRow: View { } if let date = session.lastMessageDate { - ConversationTimestamp(date: date) + ConversationTimestamp(date: date, referenceDate: referenceDate) } } diff --git a/PocketMesh/Views/Components/BLEStatusIndicatorView.swift b/PocketMesh/Views/Components/BLEStatusIndicatorView.swift index 2269f6a5..bf5f2b81 100644 --- a/PocketMesh/Views/Components/BLEStatusIndicatorView.swift +++ b/PocketMesh/Views/Components/BLEStatusIndicatorView.swift @@ -1,6 +1,7 @@ import OSLog import SwiftUI import TipKit +import CoreLocation import PocketMeshServices private let logger = Logger(subsystem: "com.pocketmesh", category: "BLEStatus") @@ -113,7 +114,7 @@ struct BLEStatusIndicatorView: View { private var autoUpdateGPSSource: GPSSource? { guard let device = appState.connectedDevice, - device.sharesLocationPublicly, + device.advertLocationPolicy > 0, devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: device.id) else { return nil } @@ -146,14 +147,24 @@ struct BLEStatusIndicatorView: View { do { switch source { case .phone: - let location = try await appState.locationService.requestCurrentLocation() + let location: CLLocation + do { + location = try await appState.locationService.requestCurrentLocation() + } catch { + guard let currentLocation = appState.locationService.currentLocation else { + throw error + } + location = currentLocation + } _ = try await settingsService?.setLocationVerified( latitude: location.coordinate.latitude, longitude: location.coordinate.longitude ) case .device: - try await settingsService?.setCustomVar(key: "gps", value: "1") - try await settingsService?.refreshDeviceInfo() + let gpsState = try await settingsService?.getDeviceGPSState() + if gpsState?.isEnabled != true { + _ = try await settingsService?.setDeviceGPSEnabledVerified(true) + } } } catch { logger.warning("Failed to update location from GPS: \(error.localizedDescription)") diff --git a/PocketMesh/Views/Onboarding/DeviceScanView.swift b/PocketMesh/Views/Onboarding/DeviceScanView.swift index d4609bfe..4a814a24 100644 --- a/PocketMesh/Views/Onboarding/DeviceScanView.swift +++ b/PocketMesh/Views/Onboarding/DeviceScanView.swift @@ -12,6 +12,7 @@ struct DeviceScanView: View { @State private var didInitiatePairing = false @State private var tapTimes: [Date] = [] @State private var showDemoModeAlert = false + @State private var otherAppDeviceID: UUID? private var demoModeManager = DemoModeManager.shared private var hasConnectedDevice: Bool { @@ -125,6 +126,26 @@ struct DeviceScanView: View { } .liquidGlassProminentButtonStyle() .disabled(appState.connectionUI.isPairing) + } else if let deviceID = otherAppDeviceID { + Button { + retryConnection(deviceID: deviceID) + } label: { + HStack(spacing: 8) { + if appState.connectionUI.isPairing { + ProgressView() + .controlSize(.small) + Text(L10n.Onboarding.DeviceScan.connecting) + } else { + Image(systemName: "arrow.clockwise.circle.fill") + Text(L10n.Onboarding.DeviceScan.retryConnection) + } + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .liquidGlassProminentButtonStyle() + .disabled(appState.connectionUI.isPairing) } else { Button { startPairing() @@ -206,6 +227,9 @@ struct DeviceScanView: View { await appState.wireServicesIfConnected() pairingSuccessTrigger.toggle() appState.onboarding.onboardingPath.append(.radioPreset) + } catch PairingError.deviceConnectedToOtherApp(let deviceID) { + otherAppDeviceID = deviceID + appState.connectionUI.otherAppWarningDeviceID = deviceID } catch AccessorySetupKitError.pickerDismissed { // User cancelled - no error to show } catch AccessorySetupKitError.pickerAlreadyActive { @@ -224,6 +248,26 @@ struct DeviceScanView: View { } } + private func retryConnection(deviceID: UUID) { + appState.connectionUI.isPairing = true + + Task { @MainActor in + defer { appState.connectionUI.isPairing = false } + + do { + try await appState.connectionManager.connect(to: deviceID) + await appState.wireServicesIfConnected() + pairingSuccessTrigger.toggle() + appState.onboarding.onboardingPath.append(.radioPreset) + } catch BLEError.deviceConnectedToOtherApp { + appState.connectionUI.otherAppWarningDeviceID = deviceID + } catch { + appState.connectionUI.connectionFailedMessage = error.localizedDescription + appState.connectionUI.showingConnectionFailedAlert = true + } + } + } + private func connectSimulator() { appState.connectionUI.isPairing = true didInitiatePairing = true diff --git a/PocketMesh/Views/Onboarding/PermissionCard.swift b/PocketMesh/Views/Onboarding/PermissionCard.swift index 1d78f8a7..e99f3750 100644 --- a/PocketMesh/Views/Onboarding/PermissionCard.swift +++ b/PocketMesh/Views/Onboarding/PermissionCard.swift @@ -50,7 +50,7 @@ struct PermissionCard: View { .foregroundStyle(.green) } else if isDenied { Button(L10n.Onboarding.Permissions.openSettings) { - if let url = URL(string: "app-settings:") { + if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) } } diff --git a/PocketMesh/Views/Onboarding/PermissionsView.swift b/PocketMesh/Views/Onboarding/PermissionsView.swift index 488cc5f5..a9d50817 100644 --- a/PocketMesh/Views/Onboarding/PermissionsView.swift +++ b/PocketMesh/Views/Onboarding/PermissionsView.swift @@ -94,7 +94,7 @@ struct PermissionsView: View { } .alert(L10n.Onboarding.Permissions.LocationAlert.title, isPresented: $showingLocationAlert) { Button(L10n.Onboarding.Permissions.LocationAlert.openSettings) { - if let url = URL(string: "app-settings:") { + if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) } } diff --git a/PocketMesh/Views/RemoteNodes/NodeAuthenticationSheet.swift b/PocketMesh/Views/RemoteNodes/NodeAuthenticationSheet.swift index f0002259..47c0aa54 100644 --- a/PocketMesh/Views/RemoteNodes/NodeAuthenticationSheet.swift +++ b/PocketMesh/Views/RemoteNodes/NodeAuthenticationSheet.swift @@ -28,6 +28,7 @@ struct NodeAuthenticationSheet: View { @State private var authStartTime: Date? @State private var authTimeoutSeconds: Int? @State private var countdownTask: Task? + @State private var authenticationTask: Task? private let maxPasswordLength = 15 @@ -57,7 +58,10 @@ struct NodeAuthenticationSheet: View { .navigationTitle(customTitle ?? (role == .roomServer ? L10n.RemoteNodes.RemoteNodes.Auth.joinRoom : L10n.RemoteNodes.RemoteNodes.Auth.adminAccess)) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(L10n.RemoteNodes.RemoteNodes.Auth.cancel) { dismiss() } + Button(L10n.RemoteNodes.RemoteNodes.Auth.cancel) { + authenticationTask?.cancel() + dismiss() + } } } .task { @@ -68,6 +72,10 @@ struct NodeAuthenticationSheet: View { } } .sensoryFeedback(.error, trigger: errorMessage) + .onDisappear { + authenticationTask?.cancel() + cleanupCountdownState() + } } } @@ -105,9 +113,10 @@ struct NodeAuthenticationSheet: View { // Clear any previous error errorMessage = nil isAuthenticating = true + authenticationTask?.cancel() cleanupCountdownState() - Task { + authenticationTask = Task { do { guard let device = appState.connectedDevice else { throw RemoteNodeError.notConnected @@ -166,12 +175,27 @@ struct NodeAuthenticationSheet: View { } await MainActor.run { + authenticationTask = nil cleanupCountdownState() dismiss() onSuccess(session) } + } catch is CancellationError { + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + isAuthenticating = false + } + } catch RemoteNodeError.timeout { + await MainActor.run { + authenticationTask = nil + cleanupCountdownState() + errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + isAuthenticating = false + } } catch { await MainActor.run { + authenticationTask = nil cleanupCountdownState() errorMessage = error.localizedDescription isAuthenticating = false diff --git a/PocketMesh/Views/RemoteNodes/RepeaterSettingsView.swift b/PocketMesh/Views/RemoteNodes/RepeaterSettingsView.swift index ec187ff4..4b2571da 100644 --- a/PocketMesh/Views/RemoteNodes/RepeaterSettingsView.swift +++ b/PocketMesh/Views/RemoteNodes/RepeaterSettingsView.swift @@ -375,6 +375,7 @@ private struct IdentitySection: View { .textContentType(.name) .submitLabel(.done) .focused(focusedField, equals: .identityName) + .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } } HStack { @@ -420,6 +421,7 @@ private struct IdentitySection: View { } label: { Label(L10n.RemoteNodes.RemoteNodes.Settings.pickOnMap, systemImage: "mappin.and.ellipse") } + .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } Button { Task { await viewModel.applyIdentitySettings() } diff --git a/PocketMesh/Views/RemoteNodes/RepeaterStatusViewModel.swift b/PocketMesh/Views/RemoteNodes/RepeaterStatusViewModel.swift index e9e44686..97902364 100644 --- a/PocketMesh/Views/RemoteNodes/RepeaterStatusViewModel.swift +++ b/PocketMesh/Views/RemoteNodes/RepeaterStatusViewModel.swift @@ -127,16 +127,7 @@ final class RepeaterStatusViewModel { // MARK: - Status /// Timeout duration for status/neighbors requests - private static let requestTimeout: Duration = .seconds(15) - - /// Timeout task for status request - private var statusTimeoutTask: Task? - - /// Timeout task for neighbors request - private var neighborsTimeoutTask: Task? - - /// Timeout task for telemetry request - private var telemetryTimeoutTask: Task? + private static let requestTimeout: Duration = RemoteOperationTimeoutPolicy.binaryMaximum /// Check if error is a transient "not ready" error that should be retried. /// Error code 10 occurs when the firmware isn't fully ready after login. @@ -149,58 +140,67 @@ final class RepeaterStatusViewModel { return code == 10 } - private static let statusRetryDelays: [Duration] = [ + private static let transientRetryDelays: [Duration] = [ .milliseconds(500), .seconds(1), .seconds(2), ] - private func requestStatusWithRetries(sessionID: UUID) async throws -> RemoteNodeStatus { - guard let repeaterAdminService else { - throw RemoteNodeError.notConnected + private func remainingBudget(until deadline: ContinuousClock.Instant) -> Duration? { + let remaining = deadline - .now + return remaining > .zero ? remaining : nil + } + + private func waitForRetry(delay: Duration, until deadline: ContinuousClock.Instant) async throws { + guard let remaining = remainingBudget(until: deadline) else { + throw RemoteNodeError.timeout } + try await Task.sleep(for: min(delay, remaining)) + } + + private func performWithTransientRetries( + operationName: String, + operation: @escaping @Sendable (Duration) async throws -> T + ) async throws -> T { + let deadline = ContinuousClock.now.advanced(by: Self.requestTimeout) + var delayIterator = Self.transientRetryDelays.makeIterator() - var delayIterator = Self.statusRetryDelays.makeIterator() while true { + guard let timeout = remainingBudget(until: deadline) else { + logger.warning("\(operationName, privacy: .public) request exhausted its shared timeout budget") + throw RemoteNodeError.timeout + } + do { - return try await repeaterAdminService.requestStatus(sessionID: sessionID) + return try await operation(timeout) } catch { guard isTransientError(error), let delay = delayIterator.next() else { throw error } - try? await Task.sleep(for: delay) + try await waitForRetry(delay: delay, until: deadline) } } } /// Request status from the repeater func requestStatus(for session: RemoteNodeSessionDTO) async { - guard repeaterAdminService != nil else { return } + guard let repeaterAdminService else { return } self.session = session isLoadingStatus = true errorMessage = nil - // Start timeout - statusTimeoutTask?.cancel() - statusTimeoutTask = Task { [weak self] in - try? await Task.sleep(for: Self.requestTimeout) - guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - if self?.isLoadingStatus == true && self?.status == nil { - self?.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut - self?.isLoadingStatus = false - } - } - } - do { - let response = try await requestStatusWithRetries(sessionID: session.id) + let response = try await performWithTransientRetries(operationName: "status") { [repeaterAdminService] timeout in + return try await repeaterAdminService.requestStatus(sessionID: session.id, timeout: timeout) + } await handleStatusResponse(response) + } catch RemoteNodeError.timeout { + errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + isLoadingStatus = false } catch { errorMessage = error.localizedDescription isLoadingStatus = false - statusTimeoutTask?.cancel() } } @@ -212,28 +212,18 @@ final class RepeaterStatusViewModel { isLoadingNeighbors = true errorMessage = nil - // Start timeout - neighborsTimeoutTask?.cancel() - neighborsTimeoutTask = Task { [weak self] in - try? await Task.sleep(for: Self.requestTimeout) - guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - if self?.isLoadingNeighbors == true && (self?.neighbors.isEmpty ?? true) { - self?.errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut - self?.isLoadingNeighbors = false - } - } - } - do { - let response = try await repeaterAdminService.requestNeighbors(sessionID: session.id) + let response = try await performWithTransientRetries(operationName: "neighbors") { [repeaterAdminService] timeout in + return try await repeaterAdminService.requestNeighbors(sessionID: session.id, timeout: timeout) + } handleNeighboursResponse(response) + } catch RemoteNodeError.timeout { + errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + isLoadingNeighbors = false } catch { errorMessage = error.localizedDescription - isLoadingNeighbors = false // Only clear on error - neighborsTimeoutTask?.cancel() + isLoadingNeighbors = false } - // Note: Don't clear isLoadingNeighbors here - it's cleared by handleNeighboursResponse } /// Handle status response from push notification @@ -244,7 +234,6 @@ final class RepeaterStatusViewModel { response.publicKeyPrefix == expectedPrefix else { return // Ignore responses for other sessions } - statusTimeoutTask?.cancel() // Cancel timeout on success self.status = response self.isLoadingStatus = false @@ -292,7 +281,6 @@ final class RepeaterStatusViewModel { /// Handle neighbours response from push notification func handleNeighboursResponse(_ response: NeighboursResponse) { // Note: NeighboursResponse may not include source prefix - validate if available - neighborsTimeoutTask?.cancel() // Cancel timeout on success self.neighbors = response.neighbours self.isLoadingNeighbors = false self.neighborsLoaded = true @@ -316,37 +304,19 @@ final class RepeaterStatusViewModel { self.session = session isLoadingTelemetry = true - - // Start timeout - telemetryTimeoutTask?.cancel() - telemetryTimeoutTask = Task { [weak self] in - try? await Task.sleep(for: Self.requestTimeout) - guard !Task.isCancelled else { return } - await MainActor.run { [weak self] in - if self?.isLoadingTelemetry == true && self?.telemetry == nil { - self?.isLoadingTelemetry = false - } - } - } + errorMessage = nil do { - let response = try await repeaterAdminService.requestTelemetry(sessionID: session.id) + let response = try await performWithTransientRetries(operationName: "telemetry") { [repeaterAdminService] timeout in + return try await repeaterAdminService.requestTelemetry(sessionID: session.id, timeout: timeout) + } handleTelemetryResponse(response) + } catch RemoteNodeError.timeout { + errorMessage = L10n.RemoteNodes.RemoteNodes.Status.requestTimedOut + isLoadingTelemetry = false } catch { - // Retry once on transient "not ready" errors (error code 10) - if isTransientError(error) { - try? await Task.sleep(for: .milliseconds(500)) - do { - let response = try await repeaterAdminService.requestTelemetry(sessionID: session.id) - handleTelemetryResponse(response) - return - } catch { - // Retry failed, fall through to show error - } - } errorMessage = error.localizedDescription isLoadingTelemetry = false - telemetryTimeoutTask?.cancel() } } @@ -357,7 +327,6 @@ final class RepeaterStatusViewModel { response.publicKeyPrefix == expectedPrefix else { return // Ignore responses for other sessions } - telemetryTimeoutTask?.cancel() // Cancel timeout on success self.telemetry = response // Decode and cache data points once to avoid repeated LPP decoding during view updates self.cachedDataPoints = response.dataPoints diff --git a/PocketMesh/Views/RemoteNodes/RoomConversationView.swift b/PocketMesh/Views/RemoteNodes/RoomConversationView.swift index 9036d023..9f114d87 100644 --- a/PocketMesh/Views/RemoteNodes/RoomConversationView.swift +++ b/PocketMesh/Views/RemoteNodes/RoomConversationView.swift @@ -161,7 +161,8 @@ struct RoomConversationView: View { text: $viewModel.composingText, isFocused: $isInputFocused, placeholder: L10n.RemoteNodes.RemoteNodes.Room.publicMessage, - maxBytes: ProtocolLimits.maxDirectMessageLength + maxBytes: ProtocolLimits.maxDirectMessageLength, + isEncrypted: false ) { text in scrollToBottomRequest += 1 Task { await viewModel.sendMessage(text: text) } diff --git a/PocketMesh/Views/Settings/LocationPickerView.swift b/PocketMesh/Views/Settings/LocationPickerView.swift index 082cdb5a..35a33c88 100644 --- a/PocketMesh/Views/Settings/LocationPickerView.swift +++ b/PocketMesh/Views/Settings/LocationPickerView.swift @@ -221,15 +221,42 @@ extension LocationPickerView { let initialCoord = device.map { CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) } + let devicePreferenceStore = DevicePreferenceStore() return LocationPickerView(initialCoordinate: initialCoord) { coordinate in guard let settingsService = appState.services?.settingsService else { throw SettingsServiceError.notConnected } - _ = try await settingsService.setLocationVerified( + let previousDevice = appState.connectedDevice + let verifiedInfo = try await settingsService.setManualLocationVerified( latitude: coordinate.latitude, longitude: coordinate.longitude ) + + guard let previousDevice else { return } + + let wasUsingDeviceGPSAutoUpdate = + devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: previousDevice.id) && + devicePreferenceStore.gpsSource(deviceID: previousDevice.id) == .device + + if wasUsingDeviceGPSAutoUpdate { + devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: previousDevice.id) + } + + if previousDevice.sharesLocationPublicly, + previousDevice.advertLocationPolicyMode == .share { + let telemetryModes = TelemetryModes( + base: verifiedInfo.telemetryModeBase, + location: verifiedInfo.telemetryModeLocation, + environment: verifiedInfo.telemetryModeEnvironment + ) + _ = try await settingsService.setOtherParamsVerified( + autoAddContacts: !verifiedInfo.manualAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: .prefs, + multiAcks: verifiedInfo.multiAcks + ) + } } } } diff --git a/PocketMesh/Views/Settings/Sections/LocationSettingsSection.swift b/PocketMesh/Views/Settings/Sections/LocationSettingsSection.swift index b693b4fb..5652f483 100644 --- a/PocketMesh/Views/Settings/Sections/LocationSettingsSection.swift +++ b/PocketMesh/Views/Settings/Sections/LocationSettingsSection.swift @@ -8,12 +8,15 @@ private let logger = Logger(subsystem: "com.pocketmesh", category: "LocationSett struct LocationSettingsSection: View { @Environment(\.appState) private var appState @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL @Binding var showingLocationPicker: Bool @State private var shareLocation = false @State private var autoUpdateLocation = false @State private var gpsSource: GPSSource = .phone @State private var deviceHasGPS = false + @State private var deviceGPSEnabled = false @State private var showError: String? + @State private var showLocationDeniedAlert = false @State private var retryAlert = RetryAlertState() @State private var isSaving = false @State private var didLoad = false @@ -21,84 +24,86 @@ struct LocationSettingsSection: View { private let devicePreferenceStore = DevicePreferenceStore() private var shouldPollDeviceGPS: Bool { - autoUpdateLocation && gpsSource == .device + autoUpdateLocation && gpsSource == .device && deviceGPSEnabled } var body: some View { - Section { - // Set Location - Button { - showingLocationPicker = true - } label: { - HStack { - TintedLabel(L10n.Settings.Node.setLocation, systemImage: "mappin.and.ellipse") - Spacer() - if let device = appState.connectedDevice, - device.latitude != 0 || device.longitude != 0 { - Text(L10n.Settings.Node.locationSet) - .foregroundStyle(.secondary) - } else { - Text(L10n.Settings.Node.locationNotSet) + Group { + Section { + Button { + showingLocationPicker = true + } label: { + HStack { + TintedLabel(L10n.Settings.Node.setLocation, systemImage: "mappin.and.ellipse") + Spacer() + if let device = appState.connectedDevice, + device.latitude != 0 || device.longitude != 0 { + Text(L10n.Settings.Node.locationSet) + .foregroundStyle(.secondary) + } else { + Text(L10n.Settings.Node.locationNotSet) + .foregroundStyle(.tertiary) + } + Image(systemName: "chevron.right") + .font(.caption) .foregroundStyle(.tertiary) } - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(.tertiary) } - } - .foregroundStyle(.primary) - .radioDisabled(for: appState.connectionState, or: isSaving || autoUpdateLocation) + .foregroundStyle(.primary) + .radioDisabled(for: appState.connectionState, or: isSaving || autoUpdateLocation) - // Share Location Publicly - Toggle(isOn: $shareLocation) { - TintedLabel(L10n.Settings.Node.shareLocationPublicly, systemImage: "location") - } - .onChange(of: shareLocation) { _, newValue in - guard didLoad else { return } - updateShareLocation(newValue) - } - .radioDisabled(for: appState.connectionState, or: isSaving) + Toggle(isOn: $shareLocation) { + TintedLabel(L10n.Settings.Node.shareLocationPublicly, systemImage: "location") + } + .onChange(of: shareLocation) { _, newValue in + guard didLoad else { return } + updateShareLocation(newValue) + } + .radioDisabled(for: appState.connectionState, or: isSaving) - // Auto-Update Location - Toggle(isOn: $autoUpdateLocation) { - TintedLabel(L10n.Settings.Location.autoUpdate, systemImage: "location.circle") - } - .onChange(of: autoUpdateLocation) { _, newValue in - guard let deviceID = appState.connectedDevice?.id else { return } - devicePreferenceStore.setAutoUpdateLocationEnabled(newValue, deviceID: deviceID) - if newValue { - if gpsSource == .phone { - appState.locationService.requestPermissionIfNeeded() - } + Toggle(isOn: $autoUpdateLocation) { + TintedLabel(L10n.Settings.Location.autoUpdate, systemImage: "location.circle") } - if !newValue, gpsSource == .device { - disableDeviceGPS() + .onChange(of: autoUpdateLocation) { _, newValue in + handleAutoUpdateChange(newValue) } - } - .radioDisabled(for: appState.connectionState, or: isSaving) + .radioDisabled(for: appState.connectionState, or: isSaving) - // GPS Source (only show picker when device has GPS hardware) - if autoUpdateLocation, deviceHasGPS { - Picker(L10n.Settings.Location.gpsSource, selection: $gpsSource) { - Text(L10n.Settings.Location.GpsSource.phone).tag(GPSSource.phone) - Text(L10n.Settings.Location.GpsSource.device).tag(GPSSource.device) + if autoUpdateLocation { + if deviceHasGPS { + Picker(L10n.Settings.Location.gpsSource, selection: $gpsSource) { + Text(L10n.Settings.Location.GpsSource.phone).tag(GPSSource.phone) + Text(L10n.Settings.Location.GpsSource.device).tag(GPSSource.device) + } + .onChange(of: gpsSource) { oldValue, newValue in + handleGPSSourceChange(from: oldValue, to: newValue) + } + .radioDisabled(for: appState.connectionState, or: isSaving) + } else { + LabeledContent(L10n.Settings.Location.gpsSource) { + Text(L10n.Settings.Location.GpsSource.phone) + .foregroundStyle(.secondary) + } + } } - .onChange(of: gpsSource) { _, newValue in - guard let deviceID = appState.connectedDevice?.id else { return } - devicePreferenceStore.setGPSSource(newValue, deviceID: deviceID) - if newValue == .phone { - appState.locationService.requestPermissionIfNeeded() - disableDeviceGPS() - } else if newValue == .device { - enableDeviceGPS() + } header: { + Text(L10n.Settings.Location.header) + } footer: { + Text(L10n.Settings.Location.footer) + } + + if deviceHasGPS { + Section { + Toggle(isOn: deviceGPSBinding) { + TintedLabel(L10n.Settings.Location.DeviceGps.toggle, systemImage: "location.circle") } + .radioDisabled(for: appState.connectionState, or: isSaving) + } header: { + Text(L10n.Settings.Location.DeviceGps.header) + } footer: { + Text(L10n.Settings.Location.DeviceGps.footer) } - .radioDisabled(for: appState.connectionState, or: isSaving) } - } header: { - Text(L10n.Settings.Location.header) - } footer: { - Text(L10n.Settings.Location.footer) } .task(id: startupTaskID) { loadPreferences() @@ -106,7 +111,7 @@ struct LocationSettingsSection: View { logger.debug("Deferring location settings startup reads until sync is less contended") return } - await queryDeviceGPSCapability() + await loadDeviceGPSState() } .task(id: shouldPollDeviceGPS) { guard shouldPollDeviceGPS, @@ -120,64 +125,128 @@ struct LocationSettingsSection: View { } .errorAlert($showError) .retryAlert(retryAlert) + .alert(L10n.Onboarding.Permissions.LocationAlert.title, isPresented: $showLocationDeniedAlert) { + Button(L10n.Onboarding.Permissions.LocationAlert.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } + } + Button(L10n.Localizable.Common.cancel, role: .cancel) { } + } message: { + Text(L10n.Onboarding.Permissions.LocationAlert.message) + } } private var startupTaskID: String { let deviceID = appState.connectedDevice?.id.uuidString ?? "none" let syncPhase = appState.connectionUI.currentSyncPhase.map { String(describing: $0) } ?? "none" - return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)" + return "\(deviceID)-\(String(describing: appState.connectionState))-\(syncPhase)-picker:\(showingLocationPicker)" + } + + private var deviceGPSBinding: Binding { + Binding( + get: { deviceGPSEnabled }, + set: { updateDeviceGPSToggle($0) } + ) } private func loadPreferences() { if let device = appState.connectedDevice { - shareLocation = device.advertLocationPolicy == 1 + shareLocation = device.sharesLocationPublicly autoUpdateLocation = devicePreferenceStore.isAutoUpdateLocationEnabled(deviceID: device.id) gpsSource = devicePreferenceStore.gpsSource(deviceID: device.id) } didLoad = true } - private func queryDeviceGPSCapability() async { + private func loadDeviceGPSState() async { guard appState.canRunSettingsStartupReads else { return } guard let settingsService = appState.services?.settingsService else { return } do { - let vars = try await settingsService.getCustomVars() - deviceHasGPS = vars.keys.contains("gps") - // If device GPS is already active, default to device source on first visit + let state = try await settingsService.getDeviceGPSState() + deviceHasGPS = state.isSupported + deviceGPSEnabled = state.isEnabled if let deviceID = appState.connectedDevice?.id, - vars["gps"] == "1", + state.isEnabled, !devicePreferenceStore.hasSetGPSSource(deviceID: deviceID) { gpsSource = .device devicePreferenceStore.setGPSSource(.device, deviceID: deviceID) } - // Refresh device info to pick up GPS-derived coordinates - if vars["gps"] == "1" { + if state.isEnabled { try? await settingsService.refreshDeviceInfo() } } catch { deviceHasGPS = false + deviceGPSEnabled = false } } - private func enableDeviceGPS() { - guard let settingsService = appState.services?.settingsService else { return } - Task { - do { - try await settingsService.setCustomVar(key: "gps", value: "1") - try await settingsService.refreshDeviceInfo() - } catch { - logger.warning("Failed to enable device GPS: \(error.localizedDescription)") + private func handleAutoUpdateChange(_ newValue: Bool) { + guard let deviceID = appState.connectedDevice?.id else { return } + if newValue, gpsSource == .phone, appState.locationService.isLocationDenied { + autoUpdateLocation = false + showLocationDeniedAlert = true + return + } + devicePreferenceStore.setAutoUpdateLocationEnabled(newValue, deviceID: deviceID) + if newValue, gpsSource == .phone { + appState.locationService.requestPermissionIfNeeded() + } + if gpsSource == .device { + if newValue { + saveDeviceGPS( + true, + onFailure: { + autoUpdateLocation = false + devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) + } + ) + } else if deviceGPSEnabled { + saveDeviceGPS(false) } } + if shareLocation { + updateShareLocation(shareLocation) + } } - private func disableDeviceGPS() { - guard let settingsService = appState.services?.settingsService else { return } - Task { - do { - try await settingsService.setCustomVar(key: "gps", value: "0") - } catch { - logger.warning("Failed to disable device GPS: \(error.localizedDescription)") + private func handleGPSSourceChange(from oldValue: GPSSource, to newValue: GPSSource) { + guard let deviceID = appState.connectedDevice?.id else { return } + if newValue == .phone, appState.locationService.isLocationDenied { + gpsSource = oldValue + showLocationDeniedAlert = true + return + } + + devicePreferenceStore.setGPSSource(newValue, deviceID: deviceID) + if newValue == .phone { + appState.locationService.requestPermissionIfNeeded() + if deviceGPSEnabled { + saveDeviceGPS(false) + } + } else if autoUpdateLocation { + saveDeviceGPS( + true, + onFailure: { + gpsSource = oldValue + devicePreferenceStore.setGPSSource(oldValue, deviceID: deviceID) + } + ) + } + + if shareLocation { + updateShareLocation(shareLocation) + } + } + + private func updateDeviceGPSToggle(_ enabled: Bool) { + guard let deviceID = appState.connectedDevice?.id else { return } + let shouldDisableAutoUpdate = !enabled && autoUpdateLocation && gpsSource == .device + saveDeviceGPS(enabled) { + if shouldDisableAutoUpdate { + autoUpdateLocation = false + devicePreferenceStore.setAutoUpdateLocationEnabled(false, deviceID: deviceID) + try await applyDeviceGPSDisabledSharePolicyIfNeeded() } } } @@ -185,21 +254,16 @@ struct LocationSettingsSection: View { private func updateShareLocation(_ share: Bool) { guard let device = appState.connectedDevice, let settingsService = appState.services?.settingsService else { return } + let policy = selectedAdvertLocationPolicy(share: share) + + if device.advertLocationPolicyMode == policy { + return + } isSaving = true Task { do { - let telemetryModes = TelemetryModes( - base: device.telemetryModeBase, - location: device.telemetryModeLoc, - environment: device.telemetryModeEnv - ) - _ = try await settingsService.setOtherParamsVerified( - autoAddContacts: !device.manualAddContacts, - telemetryModes: telemetryModes, - shareLocationPublicly: share, - multiAcks: device.multiAcks - ) + try await applyShareLocationPolicy(policy, using: device, settingsService: settingsService) retryAlert.reset() } catch let error as SettingsServiceError where error.isRetryable { shareLocation = !share @@ -215,4 +279,75 @@ struct LocationSettingsSection: View { isSaving = false } } + + private func selectedAdvertLocationPolicy(share: Bool) -> AdvertLocationPolicy { + guard share else { return .none } + if autoUpdateLocation, deviceHasGPS, gpsSource == .device { + return .share + } + return .prefs + } + + private func saveDeviceGPS( + _ enabled: Bool, + onSuccess: (@MainActor () async throws -> Void)? = nil, + onFailure: (@MainActor () -> Void)? = nil + ) { + guard let settingsService = appState.services?.settingsService else { return } + + let previousEnabled = deviceGPSEnabled + isSaving = true + + Task { + do { + let state = try await settingsService.setDeviceGPSEnabledVerified(enabled) + deviceHasGPS = state.isSupported + deviceGPSEnabled = state.isEnabled + if let onSuccess { + try await onSuccess() + } + retryAlert.reset() + } catch let error as SettingsServiceError where error.isRetryable { + deviceGPSEnabled = previousEnabled + onFailure?() + retryAlert.show( + message: error.errorDescription ?? L10n.Settings.Alert.Retry.fallbackMessage, + onRetry: { saveDeviceGPS(enabled, onSuccess: onSuccess, onFailure: onFailure) }, + onMaxRetriesExceeded: { dismiss() } + ) + } catch { + deviceGPSEnabled = previousEnabled + onFailure?() + showError = error.localizedDescription + } + isSaving = false + } + } + + private func applyDeviceGPSDisabledSharePolicyIfNeeded() async throws { + guard shareLocation, + let device = appState.connectedDevice, + device.advertLocationPolicyMode == .share, + let settingsService = appState.services?.settingsService else { return } + + try await applyShareLocationPolicy(.prefs, using: device, settingsService: settingsService) + } + + private func applyShareLocationPolicy( + _ policy: AdvertLocationPolicy, + using device: DeviceDTO, + settingsService: SettingsService + ) async throws { + let telemetryModes = TelemetryModes( + base: device.telemetryModeBase, + location: device.telemetryModeLoc, + environment: device.telemetryModeEnv + ) + _ = try await settingsService.setOtherParamsVerified( + autoAddContacts: !device.manualAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: policy, + multiAcks: device.multiAcks + ) + } } diff --git a/PocketMesh/Views/Settings/Sections/NodesSettingsSection.swift b/PocketMesh/Views/Settings/Sections/NodesSettingsSection.swift index 5d8de7f7..33d8ec4d 100644 --- a/PocketMesh/Views/Settings/Sections/NodesSettingsSection.swift +++ b/PocketMesh/Views/Settings/Sections/NodesSettingsSection.swift @@ -16,6 +16,7 @@ struct NodesSettingsSection: View { @State private var autoAddRepeaters = false @State private var autoAddRoomServers = false @State private var overwriteOldest = false + @State private var autoAddMaxHops: UInt8 = 0 private var device: DeviceDTO? { appState.connectedDevice } @@ -24,6 +25,10 @@ struct NodesSettingsSection: View { device?.supportsAutoAddConfig ?? false } + private var supportsAutoAddMaxHops: Bool { + device?.supportsAutoAddMaxHops ?? false + } + private var settingsModified: Bool { guard let device else { return false } if device.supportsAutoAddConfig { @@ -31,7 +36,8 @@ struct NodesSettingsSection: View { autoAddContacts != device.autoAddContacts || autoAddRepeaters != device.autoAddRepeaters || autoAddRoomServers != device.autoAddRoomServers || - overwriteOldest != device.overwriteOldest + overwriteOldest != device.overwriteOldest || + autoAddMaxHops != device.autoAddMaxHops } else { let deviceMode: AutoAddMode = device.manualAddContacts ? .manual : .all return autoAddMode != deviceMode @@ -51,6 +57,8 @@ struct NodesSettingsSection: View { hasher.combine(appState.connectedDevice?.autoAddRoomServers) hasher.combine(appState.connectedDevice?.overwriteOldest) hasher.combine(appState.connectedDevice?.supportsAutoAddConfig) + hasher.combine(appState.connectedDevice?.autoAddMaxHops) + hasher.combine(appState.connectedDevice?.supportsAutoAddMaxHops) return hasher.finalize() } @@ -65,7 +73,7 @@ struct NodesSettingsSection: View { } .pickerStyle(.menu) .onChange(of: autoAddMode) { _, newValue in - if newValue == .selectedTypes { + if newValue != .manual { UIAccessibility.post(notification: .screenChanged, argument: nil) } } @@ -76,6 +84,25 @@ struct NodesSettingsSection: View { Toggle(L10n.Settings.Nodes.autoAddRoomServers, isOn: $autoAddRoomServers) } + if supportsAutoAddMaxHops && autoAddMode != .manual { + Picker(selection: $autoAddMaxHops) { + Text(L10n.Settings.Nodes.MaxHops.noLimit).tag(UInt8(0)) + Text(L10n.Settings.Nodes.MaxHops.directOnly).tag(UInt8(1)) + Text(L10n.Settings.Nodes.MaxHops.oneHop).tag(UInt8(2)) + ForEach(Array(2...6), id: \.self) { hops in + Text(L10n.Settings.Nodes.MaxHops.hops(hops)).tag(UInt8(hops + 1)) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Settings.Nodes.maxHops) + Text(L10n.Settings.Nodes.maxHopsDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .pickerStyle(.menu) + } + if supportsAutoAddConfig { Toggle(isOn: $overwriteOldest) { VStack(alignment: .leading, spacing: 2) { @@ -111,7 +138,7 @@ struct NodesSettingsSection: View { } header: { Text(L10n.Settings.Nodes.header) } footer: { - Text(autoAddModeDescription) + Text(footerDescription) } .radioDisabled(for: appState.connectionState, or: isApplying) .onAppear { @@ -124,6 +151,14 @@ struct NodesSettingsSection: View { .retryAlert(retryAlert) } + private var footerDescription: String { + var description = autoAddModeDescription + if supportsAutoAddMaxHops && autoAddMode != .manual && autoAddMaxHops > 0 { + description += "\n" + L10n.Settings.Nodes.MaxHops.footerActive + } + return description + } + private var autoAddModeDescription: String { switch autoAddMode { case .manual: @@ -144,6 +179,7 @@ struct NodesSettingsSection: View { autoAddRepeaters = device.autoAddRepeaters autoAddRoomServers = device.autoAddRoomServers overwriteOldest = device.overwriteOldest + autoAddMaxHops = device.autoAddMaxHops } else { // Older firmware only supports manual/all toggle via manualAddContacts autoAddMode = device.manualAddContacts ? .manual : .all @@ -173,7 +209,7 @@ struct NodesSettingsSection: View { _ = try await settingsService.setOtherParamsVerified( autoAddContacts: !manualAdd, telemetryModes: modes, - shareLocationPublicly: device.advertLocationPolicy == 1, + advertLocationPolicy: AdvertLocationPolicy(rawValue: device.advertLocationPolicy) ?? .none, multiAcks: device.multiAcks ) @@ -193,7 +229,9 @@ struct NodesSettingsSection: View { break } - _ = try await settingsService.setAutoAddConfigVerified(config) + _ = try await settingsService.setAutoAddConfigVerified( + AutoAddConfig(bitmask: config, maxHops: autoAddMaxHops) + ) } retryAlert.reset() diff --git a/PocketMesh/Views/Settings/Sections/TelemetrySettingsSection.swift b/PocketMesh/Views/Settings/Sections/TelemetrySettingsSection.swift index f0877549..5d1efa7b 100644 --- a/PocketMesh/Views/Settings/Sections/TelemetrySettingsSection.swift +++ b/PocketMesh/Views/Settings/Sections/TelemetrySettingsSection.swift @@ -137,7 +137,7 @@ struct TelemetrySettingsSection: View { _ = try await settingsService.setOtherParamsVerified( autoAddContacts: !device.manualAddContacts, telemetryModes: modes, - shareLocationPublicly: device.advertLocationPolicy == 1, + advertLocationPolicy: AdvertLocationPolicy(rawValue: device.advertLocationPolicy) ?? .none, multiAcks: device.multiAcks ) retryAlert.reset() diff --git a/PocketMesh/Views/Tools/CLI/CLICompletionEngine.swift b/PocketMesh/Views/Tools/CLI/CLICompletionEngine.swift index e5d94324..723b5bad 100644 --- a/PocketMesh/Views/Tools/CLI/CLICompletionEngine.swift +++ b/PocketMesh/Views/Tools/CLI/CLICompletionEngine.swift @@ -18,8 +18,8 @@ final class CLICompletionEngine { private static let repeaterCommands = [ "ver", "board", "clock", "clkreboot", "neighbors", "get", "set", "password", - "log", "reboot", "advert", "setperm", "tempradio", "neighbor.remove", - "region", "gps", "powersaving", "clear" + "log", "reboot", "advert", "advert.zerohop", "setperm", "tempradio", "neighbor.remove", + "region", "gps", "powersaving", "clear", "discover.neighbors" ] private static let sessionSubcommands = ["list", "local"] @@ -51,9 +51,14 @@ final class CLICompletionEngine { "rxdelay", "txdelay", "direct.txdelay", "bridge.enabled", "bridge.delay", "bridge.source", "bridge.baud", "bridge.secret", "bridge.type", - "adc.multiplier", "public.key", "prv.key", "role", "freq" + "adc.multiplier", "public.key", "prv.key", "role", "freq", + "path.hash.mode", "loop.detect", "bootloader.ver" ] + private static let pathHashModeValues = ["0", "1", "2"] + + private static let loopDetectValues = ["off", "minimal", "moderate", "strict"] + // MARK: - Node Names private(set) var nodeNames: [String] = [] @@ -107,9 +112,13 @@ final class CLICompletionEngine { return completeFirstArg(for: command, prefix: prefix) case "get", "set": - // Only complete parameter name (first arg) - guard argPosition == 1 else { return [] } - return Self.getSetParams.filter { $0.hasPrefix(prefix) }.sorted() + if argPosition == 1 { + return Self.getSetParams.filter { $0.hasPrefix(prefix) }.sorted() + } + if command == "set", argPosition == 2, parts.count >= 2 { + return completeSetValue(param: parts[1].lowercased(), prefix: parts.count > 2 ? parts[2].lowercased() : "") + } + return [] case "gps": return completeGpsArgs(argPosition: argPosition, parts: parts, prefix: prefix) @@ -162,6 +171,17 @@ final class CLICompletionEngine { return nodeNames.filter { $0.lowercased().hasPrefix(prefix) }.sorted() } + private func completeSetValue(param: String, prefix: String) -> [String] { + switch param { + case "path.hash.mode": + return Self.pathHashModeValues.filter { $0.hasPrefix(prefix) }.sorted() + case "loop.detect": + return Self.loopDetectValues.filter { $0.hasPrefix(prefix) }.sorted() + default: + return [] + } + } + private func completeGpsArgs(argPosition: Int, parts: [String], prefix: String) -> [String] { switch argPosition { case 1: diff --git a/PocketMesh/Views/Tools/CLI/CLIToolViewModel+Sessions.swift b/PocketMesh/Views/Tools/CLI/CLIToolViewModel+Sessions.swift index 53915cc5..70e9fc97 100644 --- a/PocketMesh/Views/Tools/CLI/CLIToolViewModel+Sessions.swift +++ b/PocketMesh/Views/Tools/CLI/CLIToolViewModel+Sessions.swift @@ -239,7 +239,11 @@ extension CLIToolViewModel { isWaitingForResponse = true defer { isWaitingForResponse = false } do { - _ = try await service.sendRawCommand(sessionID: session.id, command: command, timeout: .seconds(2)) + _ = try await service.sendRawCommand( + sessionID: session.id, + command: command, + timeout: .seconds(2) + ) appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) } catch RemoteNodeError.timeout { appendOutput(L10n.Tools.Tools.Cli.rebootSent, type: .success) @@ -253,13 +257,7 @@ extension CLIToolViewModel { defer { isWaitingForResponse = false } do { - let timeout = LoginTimeoutConfig.timeout(forPathLength: session.pathLength) - - let response = try await service.sendRawCommand( - sessionID: session.id, - command: command, - timeout: timeout - ) + let response = try await service.sendRawCommand(sessionID: session.id, command: command) guard !Task.isCancelled else { return } diff --git a/PocketMeshServices/Sources/PocketMeshServices/BLEReconnectionCoordinator.swift b/PocketMeshServices/Sources/PocketMeshServices/BLEReconnectionCoordinator.swift index 1ad1a63a..6f50e9fd 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/BLEReconnectionCoordinator.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/BLEReconnectionCoordinator.swift @@ -20,8 +20,8 @@ final class BLEReconnectionCoordinator { private var timeoutTask: Task? - /// Incremented each time a reconnection cycle starts, used to detect stale retries. - private var reconnectGeneration = 0 + /// Incremented each time a reconnection cycle starts, used to detect stale rebuilds and retries. + private(set) var reconnectGeneration = 0 /// UI timeout duration before transitioning from "connecting" to "disconnected". /// iOS auto-reconnect continues in the background even after this fires. diff --git a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+BLE.swift b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+BLE.swift index c0839919..ec386143 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+BLE.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+BLE.swift @@ -29,19 +29,35 @@ extension ConnectionManager { /// - Parameter deviceID: The UUID of the device to check /// - Returns: `true` if device appears connected to another app public func isDeviceConnectedToOtherApp(_ deviceID: UUID) async -> Bool { - // Don't check during auto-reconnect - that's our own connection let isAutoReconnecting = await stateMachine.isAutoReconnecting + let smIsConnected = await stateMachine.isConnected + let smConnectedDeviceID = await stateMachine.connectedDeviceID + let systemConnected = await stateMachine.isDeviceConnectedToSystem(deviceID) + let allSystemConnected = await stateMachine.systemConnectedPeripheralIDs() + + logger.info( + "[OtherAppCheck] device=\(deviceID.uuidString.prefix(8)), " + + "connectionState=\(String(describing: self.connectionState)), " + + "isAutoReconnecting=\(isAutoReconnecting), " + + "smIsConnected=\(smIsConnected), " + + "smConnectedDevice=\(smConnectedDeviceID?.uuidString.prefix(8) ?? "nil"), " + + "isDeviceConnectedToSystem=\(systemConnected), " + + "allSystemConnectedCount=\(allSystemConnected.count), " + + "allSystemConnected=\(allSystemConnected.map { String($0.uuidString.prefix(8)) })" + ) + + // Don't check during auto-reconnect - that's our own connection guard !isAutoReconnecting else { return false } // Don't check if we're already connected (switching devices) guard connectionState == .disconnected else { return false } // Don't report our own connection as "another app" (state restoration may have completed) - if await stateMachine.isConnected, await stateMachine.connectedDeviceID == deviceID { + if smIsConnected, smConnectedDeviceID == deviceID { return false } - return await stateMachine.isDeviceConnectedToSystem(deviceID) + return systemConnected } /// Attempts to adopt a system-connected BLE link for the *last connected* device. @@ -298,6 +314,8 @@ extension ConnectionManager { var lastError: Error = ConnectionError.connectionFailed("Unknown error") for attempt in 1...maxAttempts { + guard connectingDeviceID == deviceID else { throw CancellationError() } + do { try await performConnection(deviceID: deviceID) @@ -309,6 +327,7 @@ extension ConnectionManager { } catch { lastError = error + guard connectingDeviceID == deviceID else { throw error } // BLE precondition failures won't resolve between retries. // Exit without retrying or tripping the circuit breaker so that @@ -398,7 +417,7 @@ extension ConnectionManager { async let existingDeviceResult = newServices.dataStore.fetchDevice(id: deviceID) async let autoAddConfigResult = newSession.getAutoAddConfig() let existingDevice = try? await existingDeviceResult - let autoAddConfig = (try? await autoAddConfigResult) ?? 0 + let autoAddConfig = (try? await autoAddConfigResult) ?? MeshCore.AutoAddConfig(bitmask: 0) let repeatFreqRanges: [MeshCore.FrequencyRange] = deviceCapabilities.clientRepeat ? (try? await newSession.getRepeatFreq()) ?? [] @@ -474,6 +493,15 @@ extension ConnectionManager { /// Handles unexpected connection loss func handleConnectionLoss(deviceID: UUID, error: Error?) async { + // Don't clobber a newer connection attempt + if connectionState == .connecting { + let activeID = connectingDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + if let activeID, activeID != deviceID { + logger.info("[BLE] Ignoring connection loss for \(deviceID.uuidString.prefix(8)) — connecting to \(activeID.uuidString.prefix(8))") + return + } + } + let stateBeforeLoss = connectionState var errorInfo = "none" if let error = error as NSError? { @@ -502,6 +530,7 @@ extension ConnectionManager { logger.warning("[BLE] State → .disconnected (connection loss for device: \(deviceID.uuidString.prefix(8)))") connectionState = .disconnected + if connectingDeviceID == deviceID { connectingDeviceID = nil } connectedDevice = nil allowedRepeatFreqRanges = [] services = nil diff --git a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Lifecycle.swift b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Lifecycle.swift index 3c9be2c2..dd96617d 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Lifecycle.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Lifecycle.swift @@ -266,8 +266,29 @@ extension ConnectionManager { // Prevent concurrent connection attempts if connectionState == .connecting { - logger.info("Connection already in progress, ignoring request for \(deviceID)") - return + let currentDeviceID = connectingDeviceID ?? reconnectionCoordinator.reconnectingDeviceID + + if currentDeviceID == deviceID { + if connectingDeviceID == nil { + // Auto-reconnect same device — refresh UI timeout + connectionIntent = .wantsConnection(forceFullSync: forceFullSync) + persistIntent() + reconnectionCoordinator.restartTimeout(deviceID: deviceID) + } + logger.info("Connection already in progress for \(deviceID.uuidString.prefix(8)), ignoring") + return + } + + // Different device — cancel current and fall through + logger.info("Cancelling connection to \(currentDeviceID?.uuidString.prefix(8) ?? "unknown") to connect to \(deviceID.uuidString.prefix(8))") + connectingDeviceID = nil + reconnectionCoordinator.cancelTimeout() + reconnectionCoordinator.clearReconnectingDevice() + cancelResyncLoop() + stopReconnectionWatchdog() + await cleanupResources() + await transport.disconnect() + connectionState = .disconnected } // Handle already-connected cases @@ -336,6 +357,7 @@ extension ConnectionManager { // Set connecting state for immediate UI feedback connectionState = .connecting + connectingDeviceID = deviceID logger.info("Connecting to device: \(deviceID)") @@ -359,15 +381,20 @@ extension ConnectionManager { // Attempt connection with retry try await connectWithRetry(deviceID: deviceID, maxAttempts: 4) } catch { - // Differentiate cancellation in logs + guard connectingDeviceID == deviceID else { + logger.info("Connection to \(deviceID.uuidString.prefix(8)) superseded") + throw error + } if error is CancellationError { logger.info("Connection cancelled") } else { logger.warning("Connection failed: \(error.localizedDescription)") } + connectingDeviceID = nil connectionState = .disconnected throw error } + connectingDeviceID = nil } /// Disconnects from the current device. @@ -394,6 +421,7 @@ extension ConnectionManager { // Cancel any pending auto-reconnect timeout and clear device identity reconnectionCoordinator.cancelTimeout() reconnectionCoordinator.clearReconnectingDevice() + connectingDeviceID = nil // Cancel any WiFi reconnection in progress cancelWiFiReconnection() @@ -479,6 +507,7 @@ extension ConnectionManager { connectionIntent = .wantsConnection() persistIntent() + connectingDeviceID = MockDataProvider.simulatorDeviceID connectionState = .connecting do { @@ -516,11 +545,12 @@ extension ConnectionManager { // Notify observers await onConnectionReady?() + connectingDeviceID = nil connectionState = .ready await onDeviceSynced?() logger.info("Simulator connection complete") } catch { - // Cleanup on failure + connectingDeviceID = nil await cleanupConnection() throw error } @@ -587,7 +617,7 @@ extension ConnectionManager { async let existingDeviceResult = newServices.dataStore.fetchDevice(id: deviceID) async let autoAddConfigResult = newSession.getAutoAddConfig() let existingDevice = try? await existingDeviceResult - let autoAddConfig = (try? await autoAddConfigResult) ?? 0 + let autoAddConfig = (try? await autoAddConfigResult) ?? MeshCore.AutoAddConfig(bitmask: 0) let repeatFreqRanges: [MeshCore.FrequencyRange] = deviceCapabilities.clientRepeat ? (try? await newSession.getRepeatFreq()) ?? [] diff --git a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Pairing.swift b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Pairing.swift index b001cad1..d61178bd 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Pairing.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Pairing.swift @@ -18,6 +18,12 @@ extension ConnectionManager { // Show AccessorySetupKit picker let deviceID = try await accessorySetupKit.showPicker() + // Poll for other-app reconnection — ASK pairing severs existing BLE connections, + // so the other app needs time to auto-reconnect before we can detect it + if await waitForOtherAppReconnection(deviceID) { + throw PairingError.deviceConnectedToOtherApp(deviceID: deviceID) + } + // Set connecting state for immediate UI feedback connectionState = .connecting @@ -102,6 +108,33 @@ extension ConnectionManager { throw lastError } + // MARK: - Other-App Detection + + /// Polls for other-app reconnection after ASK pairing disrupts existing BLE connections. + /// ASK pairing severs the other app's BLE link; it auto-reconnects seconds later via + /// `CBConnectPeripheralOptionEnableAutoReconnect`. This method gives it time to reappear. + /// - Parameter deviceID: The UUID of the newly paired device + /// - Returns: `true` if the device was detected as connected to another app + func waitForOtherAppReconnection(_ deviceID: UUID) async -> Bool { + let maxChecks = 6 + let interval: Duration = .milliseconds(400) + + for check in 1...maxChecks { + let connected = await stateMachine.isDeviceConnectedToSystem(deviceID) + if connected { + logger.info("[OtherAppCheck] Detected other-app connection on check \(check)/\(maxChecks)") + return true + } + + if check < maxChecks { + try? await Task.sleep(for: interval) + } + } + + logger.info("[OtherAppCheck] No other-app connection detected after \(maxChecks) checks") + return false + } + // MARK: - Forget Device /// Forgets the device, removing it from paired accessories and local storage. @@ -303,9 +336,17 @@ extension ConnectionManager { /// Updates the connected device's auto-add config. /// Called by SettingsService after auto-add config is successfully changed. - public func updateAutoAddConfig(_ config: UInt8) { + public func updateAutoAddConfig(_ config: MeshCore.AutoAddConfig) { guard let device = connectedDevice else { return } - connectedDevice = device.copy { $0.autoAddConfig = config } + let updated = device.copy { + $0.autoAddConfig = config.bitmask + $0.autoAddMaxHops = config.maxHops + } + connectedDevice = updated + + Task { + do { try await services?.dataStore.saveDevice(updated) } catch { logger.error("Failed to persist auto-add config: \(error)") } + } } /// Updates the connected device's client repeat state. diff --git a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Session.swift b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Session.swift index 24023d52..2c5b4131 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Session.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+Session.swift @@ -50,6 +50,7 @@ extension ConnectionManager: BLEReconnectionDelegate { // Full sync is deferred until performInitialSync returns to foreground via onConnectionEstablished. func rebuildSession(deviceID: UUID) async throws { logger.info("[BLE] Rebuilding session for auto-reconnect: \(deviceID.uuidString.prefix(8))") + let expectedGeneration = reconnectionCoordinator.reconnectGeneration // Stop any existing session to prevent multiple receive loops racing for transport data await session?.stop() @@ -65,13 +66,18 @@ extension ConnectionManager: BLEReconnectionDelegate { throw error } - // Check after await — user may have disconnected + // Check after await — user may have disconnected or a new reconnect cycle may have started guard connectionIntent.wantsConnection else { logger.info("User disconnected during session setup") await newSession.stop() connectionState = .disconnected return } + guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { + logger.info("[BLE] rebuildSession superseded by new reconnect cycle during session setup") + await newSession.stop() + return + } guard let selfInfo = await newSession.currentSelfInfo else { logger.warning("[BLE] rebuildSession: selfInfo is nil after start()") @@ -95,6 +101,11 @@ extension ConnectionManager: BLEReconnectionDelegate { connectionState = .disconnected return } + guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { + logger.info("[BLE] rebuildSession superseded by new reconnect cycle during device query") + await newSession.stop() + return + } let newServices = ServiceContainer( session: newSession, @@ -110,6 +121,11 @@ extension ConnectionManager: BLEReconnectionDelegate { connectionState = .disconnected return } + guard reconnectionCoordinator.reconnectGeneration == expectedGeneration else { + logger.info("[BLE] rebuildSession superseded by new reconnect cycle during service wiring") + await newSession.stop() + return + } self.services = newServices @@ -117,7 +133,7 @@ extension ConnectionManager: BLEReconnectionDelegate { async let existingDeviceResult = newServices.dataStore.fetchDevice(id: deviceID) async let autoAddConfigResult = newSession.getAutoAddConfig() let existingDevice = try? await existingDeviceResult - let autoAddConfig = (try? await autoAddConfigResult) ?? 0 + let autoAddConfig = (try? await autoAddConfigResult) ?? MeshCore.AutoAddConfig(bitmask: 0) let repeatFreqRanges: [MeshCore.FrequencyRange] = capabilities.clientRepeat ? (try? await newSession.getRepeatFreq()) ?? [] @@ -132,17 +148,34 @@ extension ConnectionManager: BLEReconnectionDelegate { await onConnectionReady?() await performInitialSync(deviceID: deviceID, services: newServices, context: "[BLE] iOS auto-reconnect") - // User may have disconnected while sync was in progress - guard connectionIntent.wantsConnection else { return } + // User may have disconnected or a new reconnect cycle may have started while sync was in progress + guard connectionIntent.wantsConnection, + reconnectionCoordinator.reconnectGeneration == expectedGeneration + else { + await newSession.stop() + return + } await syncDeviceTimeIfNeeded() - guard connectionIntent.wantsConnection else { return } + guard connectionIntent.wantsConnection, + reconnectionCoordinator.reconnectGeneration == expectedGeneration + else { + await newSession.stop() + return + } // Re-authenticate room sessions that were connected before BLE loss let sessionIDs = sessionsAwaitingReauth sessionsAwaitingReauth = [] await newServices.remoteNodeService.handleBLEReconnection(sessionIDs: sessionIDs) + guard connectionIntent.wantsConnection, + reconnectionCoordinator.reconnectGeneration == expectedGeneration + else { + await newSession.stop() + return + } + currentTransportType = .bluetooth connectionState = .ready await onDeviceSynced?() diff --git a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+WiFi.swift b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+WiFi.swift index 30da3592..ac947b80 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+WiFi.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager+WiFi.swift @@ -213,7 +213,7 @@ extension ConnectionManager { async let existingDeviceResult = newServices.dataStore.fetchDevice(id: deviceID) async let autoAddConfigResult = session.getAutoAddConfig() let existingDevice = try? await existingDeviceResult - let autoAddConfig = (try? await autoAddConfigResult) ?? 0 + let autoAddConfig = (try? await autoAddConfigResult) ?? MeshCore.AutoAddConfig(bitmask: 0) let repeatFreqRanges: [MeshCore.FrequencyRange] = capabilities.clientRepeat ? (try? await session.getRepeatFreq()) ?? [] @@ -350,7 +350,7 @@ extension ConnectionManager { async let existingDeviceResult = newServices.dataStore.fetchDevice(id: deviceID) async let autoAddConfigResult = newSession.getAutoAddConfig() let existingDevice = try? await existingDeviceResult - let autoAddConfig = (try? await autoAddConfigResult) ?? 0 + let autoAddConfig = (try? await autoAddConfigResult) ?? MeshCore.AutoAddConfig(bitmask: 0) let repeatFreqRanges: [MeshCore.FrequencyRange] = deviceCapabilities.clientRepeat ? (try? await newSession.getRepeatFreq()) ?? [] diff --git a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager.swift b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager.swift index 3512757f..5b5da63a 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/ConnectionManager.swift @@ -43,11 +43,15 @@ public enum ConnectionError: LocalizedError { public enum PairingError: LocalizedError { /// ASK pairing succeeded but BLE connection failed (e.g., wrong PIN) case connectionFailed(deviceID: UUID, underlying: Error) + /// ASK pairing succeeded but device is connected to another app + case deviceConnectedToOtherApp(deviceID: UUID) public var errorDescription: String? { switch self { case .connectionFailed(_, let underlying): return "Connection failed: \(underlying.localizedDescription)" + case .deviceConnectedToOtherApp: + return "Device is connected to another app." } } @@ -56,6 +60,8 @@ public enum PairingError: LocalizedError { switch self { case .connectionFailed(let deviceID, _): return deviceID + case .deviceConnectedToOtherApp(let deviceID): + return deviceID } } } @@ -209,6 +215,10 @@ public final class ConnectionManager { /// The user's connection intent. Replaces shouldBeConnected, userExplicitlyDisconnected, and pendingForceFullSync. var connectionIntent: ConnectionIntent = .none + /// The device being actively connected via connect(to:). + /// Nil during auto-reconnect (tracked by reconnectionCoordinator.reconnectingDeviceID instead). + var connectingDeviceID: UUID? + // MARK: - Callbacks /// Called when connection is ready and services are available. @@ -711,7 +721,7 @@ public final class ConnectionManager { deviceID: UUID, selfInfo: MeshCore.SelfInfo, capabilities: MeshCore.DeviceCapabilities, - autoAddConfig: UInt8, + autoAddConfig: MeshCore.AutoAddConfig, existingDevice: DeviceDTO? = nil, connectionMethods: [ConnectionMethod] = [] ) -> Device { @@ -747,7 +757,8 @@ public final class ConnectionManager { preRepeatSpreadingFactor: existingDevice?.preRepeatSpreadingFactor, preRepeatCodingRate: existingDevice?.preRepeatCodingRate, manualAddContacts: selfInfo.manualAddContacts, - autoAddConfig: autoAddConfig, + autoAddConfig: autoAddConfig.bitmask, + autoAddMaxHops: autoAddConfig.maxHops, multiAcks: selfInfo.multiAcks, telemetryModeBase: selfInfo.telemetryModeBase, telemetryModeLoc: selfInfo.telemetryModeLocation, @@ -797,6 +808,7 @@ public final class ConnectionManager { func cleanupConnection() async { logger.info("[BLE] cleanupConnection: state → .disconnected") connectionState = .disconnected + connectingDeviceID = nil connectedDevice = nil allowedRepeatFreqRanges = [] await cleanupResources() @@ -839,7 +851,8 @@ public final class ConnectionManager { internal func setTestState( connectionState: ConnectionState? = nil, currentTransportType: TransportType?? = nil, - connectionIntent: ConnectionIntent? = nil + connectionIntent: ConnectionIntent? = nil, + connectingDeviceID: UUID?? = nil ) { suppressInvariantChecks = true defer { suppressInvariantChecks = false } @@ -853,6 +866,9 @@ public final class ConnectionManager { if let intent = connectionIntent { self.connectionIntent = intent } + if let deviceID = connectingDeviceID { + self.connectingDeviceID = deviceID + } } #endif } diff --git a/PocketMeshServices/Sources/PocketMeshServices/Models/Channel.swift b/PocketMeshServices/Sources/PocketMeshServices/Models/Channel.swift index 8d624119..e7d4ca0e 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Models/Channel.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Models/Channel.swift @@ -122,6 +122,12 @@ public extension Channel { !secret.allSatisfy { $0 == 0 } } + /// Whether this channel uses meaningful encryption (private channels only). + /// Public channels (index 0) and hashtag channels use publicly-derivable keys. + var isEncryptedChannel: Bool { + !isPublicChannel && !name.hasPrefix("#") + } + /// Updates from a protocol ChannelInfo func update(from info: ChannelInfo) { self.name = info.name @@ -221,4 +227,10 @@ public struct ChannelDTO: Sendable, Equatable, Identifiable, Hashable { public var hasSecret: Bool { !secret.allSatisfy { $0 == 0 } } + + /// Whether this channel uses meaningful encryption (private channels only). + /// Public channels (index 0) and hashtag channels use publicly-derivable keys. + public var isEncryptedChannel: Bool { + !isPublicChannel && !name.hasPrefix("#") + } } diff --git a/PocketMeshServices/Sources/PocketMeshServices/Models/Device.swift b/PocketMeshServices/Sources/PocketMeshServices/Models/Device.swift index 44fac9ea..b7dd431a 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Models/Device.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Models/Device.swift @@ -79,6 +79,9 @@ public final class Device { /// Auto-add configuration bitmask from device public var autoAddConfig: UInt8 = 0 + /// Maximum hops for auto-add filtering. 0 = no limit, 1 = direct only, N = up to N-1 hops. + public var autoAddMaxHops: UInt8 = 0 + /// Number of acknowledgments to send for direct messages (0=disabled, 1-2 typical) public var multiAcks: UInt8 @@ -139,6 +142,7 @@ public final class Device { preRepeatCodingRate: UInt8? = nil, manualAddContacts: Bool = false, autoAddConfig: UInt8 = 0, + autoAddMaxHops: UInt8 = 0, multiAcks: UInt8 = 2, telemetryModeBase: UInt8 = 2, telemetryModeLoc: UInt8 = 0, @@ -177,6 +181,7 @@ public final class Device { self.preRepeatCodingRate = preRepeatCodingRate self.manualAddContacts = manualAddContacts self.autoAddConfig = autoAddConfig + self.autoAddMaxHops = autoAddMaxHops self.multiAcks = multiAcks self.telemetryModeBase = telemetryModeBase self.telemetryModeLoc = telemetryModeLoc @@ -217,6 +222,7 @@ public final class Device { preRepeatCodingRate = dto.preRepeatCodingRate manualAddContacts = dto.manualAddContacts autoAddConfig = dto.autoAddConfig + autoAddMaxHops = dto.autoAddMaxHops multiAcks = dto.multiAcks telemetryModeBase = dto.telemetryModeBase telemetryModeLoc = dto.telemetryModeLoc @@ -265,6 +271,7 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { public var preRepeatCodingRate: UInt8? public var manualAddContacts: Bool public var autoAddConfig: UInt8 + public var autoAddMaxHops: UInt8 public var multiAcks: UInt8 public var telemetryModeBase: UInt8 public var telemetryModeLoc: UInt8 @@ -308,14 +315,24 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { firmwareVersionString.isAtLeast(major: 1, minor: 12) } + /// Whether the device supports auto-add max hops (v1.14+) + public var supportsAutoAddMaxHops: Bool { + firmwareVersionString.isAtLeast(major: 1, minor: 14) + } + /// Whether this device supports client repeat mode (firmware v9+) public var supportsClientRepeat: Bool { firmwareVersion >= 9 } /// Whether this device supports path hash mode configuration (firmware v10+) public var supportsPathHashMode: Bool { firmwareVersion >= 10 } - /// Whether location is shared publicly in advertisements (policy value 1) - public var sharesLocationPublicly: Bool { advertLocationPolicy == 1 } + /// Advertisement location policy interpreted from raw value. + public var advertLocationPolicyMode: AdvertLocationPolicy { + AdvertLocationPolicy(rawValue: advertLocationPolicy) ?? .none + } + + /// Whether location is shared publicly in advertisements. + public var sharesLocationPublicly: Bool { advertLocationPolicy > 0 } /// Whether pre-repeat radio settings are saved for restoration. public var hasPreRepeatSettings: Bool { @@ -350,6 +367,7 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { preRepeatCodingRate: UInt8? = nil, manualAddContacts: Bool, autoAddConfig: UInt8 = 0, + autoAddMaxHops: UInt8 = 0, multiAcks: UInt8, telemetryModeBase: UInt8, telemetryModeLoc: UInt8, @@ -388,6 +406,7 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { self.preRepeatCodingRate = preRepeatCodingRate self.manualAddContacts = manualAddContacts self.autoAddConfig = autoAddConfig + self.autoAddMaxHops = autoAddMaxHops self.multiAcks = multiAcks self.telemetryModeBase = telemetryModeBase self.telemetryModeLoc = telemetryModeLoc @@ -428,6 +447,7 @@ public struct DeviceDTO: Sendable, Equatable, Identifiable { self.preRepeatCodingRate = device.preRepeatCodingRate self.manualAddContacts = device.manualAddContacts self.autoAddConfig = device.autoAddConfig + self.autoAddMaxHops = device.autoAddMaxHops self.multiAcks = device.multiAcks self.telemetryModeBase = device.telemetryModeBase self.telemetryModeLoc = device.telemetryModeLoc diff --git a/PocketMeshServices/Sources/PocketMeshServices/Models/Message.swift b/PocketMeshServices/Sources/PocketMeshServices/Models/Message.swift index 39ff6ffa..72d09824 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Models/Message.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Models/Message.swift @@ -22,7 +22,9 @@ public enum MessageDirection: Int, Sendable, Codable { @Model public final class Message { #Index( + [\.deviceID, \.channelIndex, \.createdAt], [\.deviceID, \.channelIndex, \.timestamp], + [\.contactID, \.createdAt], [\.contactID, \.containsSelfMention, \.mentionSeen], [\.deviceID, \.channelIndex, \.containsSelfMention, \.mentionSeen] ) @@ -249,10 +251,6 @@ public extension Message { status == .failed } - /// Date representation of timestamp - var date: Date { - Date(timeIntervalSince1970: TimeInterval(timestamp)) - } } // MARK: - Sendable DTO @@ -427,7 +425,13 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable { status == .failed } + /// Date used for display and sorting (local receive time) public var date: Date { + createdAt + } + + /// Date derived from the sender's device clock (may differ from `date` if the sender's clock is skewed) + public var senderDate: Date { Date(timeIntervalSince1970: TimeInterval(timestamp)) } @@ -455,4 +459,64 @@ public struct MessageDTO: Sendable, Equatable, Hashable, Identifiable { public var pathStringForClipboard: String { pathNodesHex.joined(separator: ",") } + + // MARK: - Same-Sender Reordering + + /// Maximum time window (in seconds) within which consecutive messages from the same sender + /// are re-sorted by sender timestamp to preserve intended send order. + private static let sameSenderReorderWindow: TimeInterval = 5 + + /// Reorders messages within narrow same-sender clusters by sender timestamp. + /// + /// Messages are sorted by receive time (`createdAt`) for display. However, when multiple + /// messages from the same sender arrive within a short window, mesh relay may deliver them + /// out of order. This function detects those clusters and re-sorts them by the sender's + /// claimed timestamp to restore the intended conversation order. + public static func reorderSameSenderClusters(_ messages: [MessageDTO]) -> [MessageDTO] { + guard messages.count > 1 else { return messages } + + var result = messages + var clusterStart = 0 + + while clusterStart < result.count { + var clusterEnd = clusterStart + 1 + + // Extend the cluster while consecutive messages match the same sender/direction + // and fall within the reorder window + while clusterEnd < result.count { + let gap = result[clusterEnd].createdAt.timeIntervalSince(result[clusterEnd - 1].createdAt) + guard isSameSender(result[clusterEnd], result[clusterEnd - 1]), + gap <= sameSenderReorderWindow else { break } + clusterEnd += 1 + } + + // Sort the cluster by sender timestamp if it contains more than one message + if clusterEnd - clusterStart > 1 { + let sorted = result[clusterStart.. Bool { + guard a.direction == b.direction else { return false } + guard a.isChannelMessage == b.isChannelMessage else { return false } + + // For channel messages, compare sender node name (nil = unknown, treat as different). + // senderNodeName isn't unique — two users with the same name may be falsely clustered. + if a.isChannelMessage { + guard let nameA = a.senderNodeName, let nameB = b.senderNodeName else { return false } + return nameA == nameB + } + + // For DMs, same-direction messages share the same sender + return true + } } diff --git a/PocketMeshServices/Sources/PocketMeshServices/Protocols/PersistenceStoreProtocol.swift b/PocketMeshServices/Sources/PocketMeshServices/Protocols/PersistenceStoreProtocol.swift index 58b816e2..939bd630 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Protocols/PersistenceStoreProtocol.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Protocols/PersistenceStoreProtocol.swift @@ -37,6 +37,14 @@ public protocol PersistenceStoreProtocol: Actor { /// Fetch messages for a channel func fetchMessages(deviceID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] + /// Batch fetch last messages for multiple contacts in a single actor call. + /// Avoids N actor hops when loading message previews for the conversation list. + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] + + /// Batch fetch last messages for multiple channels in a single actor call. + /// Each tuple contains (deviceID, channelIndex, id) where id is used as the dictionary key. + func fetchLastChannelMessages(channels: [(deviceID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] + /// Finds a channel message matching a parsed reaction within a timestamp window func findChannelMessageForReaction( deviceID: UUID, @@ -47,6 +55,22 @@ public protocol PersistenceStoreProtocol: Actor { limit: Int ) async throws -> MessageDTO? + /// Fetches channel message candidates for meshcore-open reaction matching + func fetchChannelMessageCandidates( + deviceID: UUID, + channelIndex: UInt8, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] + + /// Fetches DM message candidates for meshcore-open reaction matching + func fetchDMMessageCandidates( + deviceID: UUID, + contactID: UUID, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] + /// Finds a DM message matching a reaction by hash within a timestamp window func findDMMessageForReaction( deviceID: UUID, diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/AccessorySetupKitService.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/AccessorySetupKitService.swift index 3cb397d3..45b03878 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Services/AccessorySetupKitService.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/AccessorySetupKitService.swift @@ -69,30 +69,6 @@ public final class AccessorySetupKitService { } } - // MARK: - Discovery Descriptors - - /// Bluetooth name prefixes for supported MeshCore devices - /// Each prefix must have a matching entry in NSAccessorySetupBluetoothNames in Info.plist - private static let supportedNamePrefixes = [ - // Primary MeshCore firmware prefix - "MeshCore-", - // Third-party/legacy firmware prefixes - "Whisper-", "WisCore", - // nRF52 board BSP defaults (devices may advertise these when firmware name isn't set) - "XIAO", "elecrow", "HT-n5262", "Seeed", "BQ", - "ProMicro", "Keepteen", "Meshtiny", "T1000-E-BOOT", - "me25ls01-BOOT", "NRF52 DK", - ] - - private var discoveryDescriptors: [ASDiscoveryDescriptor] { - Self.supportedNamePrefixes.map { prefix in - let descriptor = ASDiscoveryDescriptor() - descriptor.bluetoothServiceUUID = CBUUID(string: BLEServiceUUID.nordicUART) - descriptor.bluetoothNameSubstring = prefix - return descriptor - } - } - // MARK: - Session Management /// Activate the AccessorySetupKit session @@ -248,16 +224,19 @@ public final class AccessorySetupKitService { } } - // Create picker display items for each supported device type - // Each item has its own descriptor with a different name prefix filter + // Single display item filtered by service UUID only. + // Supported device names are declared in Info.plist (NSAccessorySetupBluetoothNames) + // for system-level authorization; the picker deduplicates by device. let productImage = createGenericProductImage() - let displayItems = discoveryDescriptors.map { descriptor in + let descriptor = ASDiscoveryDescriptor() + descriptor.bluetoothServiceUUID = CBUUID(string: BLEServiceUUID.nordicUART) + let displayItems = [ ASPickerDisplayItem( name: "MeshCore Device", productImage: productImage, descriptor: descriptor ) - } + ] return try await withCheckedThrowingContinuation { continuation in self.pickerContinuation = continuation diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/MeshCoreOpenReactionParser.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/MeshCoreOpenReactionParser.swift new file mode 100644 index 00000000..bd557854 --- /dev/null +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/MeshCoreOpenReactionParser.swift @@ -0,0 +1,227 @@ +import Foundation + +/// Parsed meshcore-open v3 reaction data +public struct ParsedMCOReaction: Sendable, Equatable { + public let emoji: String + public let dartHash: String // 4 lowercase hex chars +} + +/// Parsed meshcore-open v1 reaction data (pre-Jan 2026 clients) +/// +/// The `senderNameHash` and `textHash` are full Dart VM `String.hashCode` values +/// (30-bit, decimal-encoded on the wire). For DM reactions, `senderNameHash` is +/// not verified during matching since DMs have implicit sender context. +public struct ParsedMCOReactionV1: Sendable, Equatable { + public let emoji: String + public let timestampSeconds: UInt32 + public let senderNameHash: UInt32 + public let textHash: UInt32 + + /// Reconstructs the original v1 messageId, used as the opaque reaction hash for dedup. + public var messageIdHash: String { + "\(timestampSeconds)_\(senderNameHash)_\(textHash)" + } +} + +/// Parses meshcore-open reaction wire format (receive-only). +/// +/// meshcore-open sends reactions as `r:{4-char-hash}:{2-char-emoji-index}`. +/// The hash is computed using the Dart VM's `String.hashCode` algorithm +/// masked to 16 bits. +public enum MeshCoreOpenReactionParser { + + // MARK: - Parsing + + /// Parses a meshcore-open reaction string. + /// + /// Format: `r:{4-char-hex-hash}:{2-char-hex-emoji-index}` + /// - Returns: Parsed emoji and dart hash, or nil if format doesn't match. + public static func parse(_ text: String) -> ParsedMCOReaction? { + guard text.count == 9, + text.hasPrefix("r:"), + text[text.index(text.startIndex, offsetBy: 6)] == ":" else { + return nil + } + + let hashStart = text.index(text.startIndex, offsetBy: 2) + let hashEnd = text.index(text.startIndex, offsetBy: 6) + let indexStart = text.index(text.startIndex, offsetBy: 7) + + let hashStr = String(text[hashStart.. ParsedMCOReactionV1? { + guard text.hasPrefix("r:") else { return nil } + + // Split on last ":" to separate messageId from emoji + guard let lastColon = text.lastIndex(of: ":"), + lastColon > text.index(text.startIndex, offsetBy: 2) else { + return nil + } + + let messageId = String(text[text.index(text.startIndex, offsetBy: 2)..> 6 + /// finalize: + /// hash += hash << 3 + /// hash ^= hash >> 11 + /// hash += hash << 15 + /// hash &= (1 << 30) - 1 + /// if hash == 0: hash = 1 + /// ``` + /// Convenience overload that hashes a String's UTF-16 code units directly. + public static func dartStringHash(_ string: String) -> UInt32 { + dartStringHash(Array(string.utf16)) + } + + public static func dartStringHash(_ codeUnits: [UInt16]) -> UInt32 { + var hash: UInt32 = 0 + + for unit in codeUnits { + hash = hash &+ UInt32(unit) + hash = hash &+ (hash &<< 10) + hash ^= (hash >> 6) + } + + // Finalize + hash = hash &+ (hash &<< 3) + hash ^= (hash >> 11) + hash = hash &+ (hash &<< 15) + + // Mask to 30 bits + let kHashBitMask: UInt32 = (1 << 30) - 1 + hash &= kHashBitMask + + if hash == 0 { hash = 1 } + return hash + } + + /// Computes the reaction hash used by meshcore-open. + /// + /// Builds UTF-16 code units from: `"\(timestamp)" + senderName + first5UTF16(text)` + /// Then runs `dartStringHash`, masks to 16 bits, and formats as 4 lowercase hex chars. + /// + /// - Parameters: + /// - timestamp: Message timestamp as UInt32 + /// - senderName: Sender node name (nil for DM reactions) + /// - text: Message text content + /// - Returns: 4-character lowercase hex hash string + public static func computeReactionHash( + timestamp: UInt32, + senderName: String?, + text: String + ) -> String { + var codeUnits: [UInt16] = [] + + // Append timestamp as string + codeUnits.append(contentsOf: String(timestamp).utf16) + + // Append sender name (channel only) + if let senderName { + codeUnits.append(contentsOf: senderName.utf16) + } + + // Append first 5 UTF-16 code units of text + codeUnits.append(contentsOf: text.utf16.prefix(5)) + + let hash = dartStringHash(codeUnits) & 0xFFFF + return String(format: "%04x", hash) + } + + // MARK: - Emoji Table + + /// The 184-emoji lookup table from meshcore-open's emoji_picker.dart. + /// Concatenated in order: quickEmojis + smileys + gestures + hearts + objects. + static let emojiTable: [String] = [ + // quickEmojis (0x00–0x05) + "👍", "❤️", "😂", "🎉", "👏", "🔥", + // smileys (0x06–0x45) + "😀", "😃", "😄", "😁", "😅", "😂", "🤣", "😊", + "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", + "😗", "😙", "😚", "😋", "😛", "😝", "😜", "🤪", + "🤨", "🧐", "🤓", "😎", "🥸", "🤩", "🥳", "😏", + "😒", "😞", "😔", "😟", "😕", "🙁", "😣", "😖", + "😫", "😩", "🥺", "😢", "😭", "😤", "😠", "😡", + "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰", + "😥", "😓", "🤗", "🤔", "🤭", "🤫", "🤥", "😶", + // gestures (0x46–0x66) + "👍", "👎", "👊", "✊", "🤛", "🤜", "🤞", "✌️", + "🤟", "🤘", "👌", "🤌", "🤏", "👈", "👉", "👆", + "👇", "☝️", "👋", "🤚", "🖐️", "✋", "🖖", "👏", + "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", + "💪", + // hearts (0x67–0x86) + "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", + "🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞", "💓", "💗", + "💖", "💘", "💝", "💟", "💌", "💢", "💥", "💫", + "💦", "💨", "🕳️", "💬", "👁️‍🗨️", "🗨️", "🗯️", "💭", + // objects (0x87–0xB7) + "🎉", "🎊", "🎈", "🎁", "🎀", "🪅", "🪆", "🏆", + "🥇", "🥈", "🥉", "⚽", "⚾", "🥎", "🏀", "🏐", + "🏈", "🏉", "🎾", "🥏", "🎳", "🏏", "🏑", "🏒", + "🥍", "🏓", "🏸", "🥊", "🥋", "🥅", "⛳", "🔥", + "⭐", "🌟", "✨", "⚡", "💡", "🔦", "🏮", "🪔", + "📱", "💻", "⌚", "📷", "📺", "📻", "🎵", "🎶", + "🚀", + ] + + // MARK: - Private Helpers + + private static func isLowercaseHex(_ string: String) -> Bool { + string.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } +} diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/MessageService+Send.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/MessageService+Send.swift index e851930f..6ecfc206 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Services/MessageService+Send.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/MessageService+Send.swift @@ -42,7 +42,7 @@ extension MessageService { let messageID = UUID() let timestamp = UInt32(Date().timeIntervalSince1970) - // Save message to store as pending FIRST + // Save message to store as pending first let messageDTO = createOutgoingMessage( id: messageID, deviceID: contact.deviceID, @@ -140,7 +140,7 @@ extension MessageService { let messageID = UUID() let timestamp = UInt32(Date().timeIntervalSince1970) - // Save message to store as pending FIRST + // Save message to store as pending first let messageDTO = createOutgoingMessage( id: messageID, deviceID: contact.deviceID, @@ -525,7 +525,7 @@ extension MessageService { let messageID = UUID() let timestamp = UInt32(Date().timeIntervalSince1970) - // Save message to store as pending FIRST + // Save message to store as pending first let messageDTO = createOutgoingChannelMessage( id: messageID, deviceID: deviceID, @@ -562,8 +562,7 @@ extension MessageService { /// /// This is used for "Send Again" - it re-transmits the same message /// rather than creating a duplicate. Uses a new timestamp so the mesh - /// treats it as a fresh broadcast, and updates the stored timestamp - /// so the message moves to the bottom of the chat. + /// treats it as a fresh broadcast. public func resendChannelMessage(messageID: UUID) async throws { guard let message = try await dataStore.fetchMessage(id: messageID) else { throw MessageServiceError.sendFailed("Message not found") @@ -582,7 +581,7 @@ extension MessageService { timestamp: now ) - // Update stored timestamp (moves message to bottom of chat) + // Update stored timestamp so the mesh treats this as a new broadcast try await dataStore.updateMessageTimestamp(id: messageID, timestamp: newTimestamp) // Increment send count diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/NodeConfigService.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/NodeConfigService.swift index c124a467..a2b923ba 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Services/NodeConfigService.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/NodeConfigService.swift @@ -386,7 +386,7 @@ public actor NodeConfigService { try await settingsService.setOtherParams( autoAddContacts: manualAdd == 0, telemetryModes: TelemetryModes(base: telBase, location: telLocation, environment: telEnvironment), - shareLocationPublicly: advertPolicy > 0, + advertLocationPolicy: AdvertLocationPolicy(rawValue: advertPolicy) ?? .none, multiAcks: multiAcks ) } diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/PersistenceStore+Devices.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/PersistenceStore+Devices.swift index 53dfdfd4..438e56e5 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Services/PersistenceStore+Devices.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/PersistenceStore+Devices.swift @@ -74,6 +74,7 @@ extension PersistenceStore { preRepeatCodingRate: dto.preRepeatCodingRate, manualAddContacts: dto.manualAddContacts, autoAddConfig: dto.autoAddConfig, + autoAddMaxHops: dto.autoAddMaxHops, multiAcks: dto.multiAcks, telemetryModeBase: dto.telemetryModeBase, telemetryModeLoc: dto.telemetryModeLoc, diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/PersistenceStore+Messages.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/PersistenceStore+Messages.swift index 5515d62f..9d90e5dc 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Services/PersistenceStore+Messages.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/PersistenceStore+Messages.swift @@ -21,6 +21,28 @@ extension PersistenceStore { // MARK: - Message Operations + /// Batch fetch last messages for multiple contacts in a single actor-isolated call. + /// Runs N fetches with zero suspension points between them, avoiding N actor hops. + public func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + result.reserveCapacity(contactIDs.count) + for contactID in contactIDs { + result[contactID] = try fetchMessages(contactID: contactID, limit: limit) + } + return result + } + + /// Batch fetch last messages for multiple channels in a single actor-isolated call. + /// Runs N fetches with zero suspension points between them, avoiding N actor hops. + public func fetchLastChannelMessages(channels: [(deviceID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + result.reserveCapacity(channels.count) + for channel in channels { + result[channel.id] = try fetchMessages(deviceID: channel.deviceID, channelIndex: channel.channelIndex, limit: limit) + } + return result + } + /// Fetch messages for a contact public func fetchMessages(contactID: UUID, limit: Int = 50, offset: Int = 0) throws -> [MessageDTO] { let targetContactID: UUID? = contactID @@ -30,15 +52,16 @@ extension PersistenceStore { var descriptor = FetchDescriptor( predicate: predicate, sortBy: [ - SortDescriptor(\Message.timestamp, order: .reverse), - SortDescriptor(\Message.createdAt, order: .reverse) + SortDescriptor(\Message.createdAt, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse) ] ) descriptor.fetchLimit = limit descriptor.fetchOffset = offset let messages = try modelContext.fetch(descriptor) - return messages.reversed().map { MessageDTO(from: $0) } + let dtos = messages.reversed().map { MessageDTO(from: $0) } + return MessageDTO.reorderSameSenderClusters(dtos) } /// Fetch messages for a channel @@ -51,15 +74,16 @@ extension PersistenceStore { var descriptor = FetchDescriptor( predicate: predicate, sortBy: [ - SortDescriptor(\Message.timestamp, order: .reverse), - SortDescriptor(\Message.createdAt, order: .reverse) + SortDescriptor(\Message.createdAt, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse) ] ) descriptor.fetchLimit = limit descriptor.fetchOffset = offset let messages = try modelContext.fetch(descriptor) - return messages.reversed().map { MessageDTO(from: $0) } + let dtos = messages.reversed().map { MessageDTO(from: $0) } + return MessageDTO.reorderSameSenderClusters(dtos) } /// Finds a channel message matching a parsed reaction within a timestamp window. @@ -75,36 +99,18 @@ extension PersistenceStore { // swiftlint:disable:next line_length logger.debug("[REACTION-MATCH] Looking for message: targetSender=\(parsedReaction.targetSender), hash=\(parsedReaction.messageHash), localNodeName=\(localNodeName ?? "nil"), window=\(timestampWindow.lowerBound)...\(timestampWindow.upperBound)") - let targetDeviceID = deviceID - let targetChannelIndex: UInt8? = channelIndex - let start = timestampWindow.lowerBound - let end = timestampWindow.upperBound - - let predicate = #Predicate { message in - message.deviceID == targetDeviceID && - message.channelIndex == targetChannelIndex && - message.timestamp >= start && - message.timestamp <= end - } - - var descriptor = FetchDescriptor( - predicate: predicate, - sortBy: [ - SortDescriptor(\Message.timestamp, order: .reverse), - SortDescriptor(\Message.createdAt, order: .reverse) - ] + let candidates = try fetchChannelMessageCandidates( + deviceID: deviceID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: limit ) - descriptor.fetchLimit = limit - - let candidates = try modelContext.fetch(descriptor) logger.debug("[REACTION-MATCH] Found \(candidates.count) candidates in window") guard !candidates.isEmpty else { return nil } for candidate in candidates { let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" - // Use senderTimestamp if available (for incoming messages with corrected timestamps) - let reactionTimestamp = candidate.senderTimestamp ?? candidate.timestamp - let candidateHash = ReactionParser.generateMessageHash(text: candidate.text, timestamp: reactionTimestamp) + let candidateHash = ReactionParser.generateMessageHash(text: candidate.text, timestamp: candidate.reactionTimestamp) logger.debug("[REACTION-MATCH] Candidate: direction=\(direction), senderNodeName=\(candidate.senderNodeName ?? "nil"), hash=\(candidateHash), text=\(candidate.text.prefix(30))") if candidate.direction == .outgoing { @@ -125,24 +131,55 @@ extension PersistenceStore { } logger.debug("[REACTION-MATCH] Found match!") - return MessageDTO(from: candidate) + return candidate } logger.debug("[REACTION-MATCH] No match found") return nil } - /// Finds a DM message matching a reaction by hash within a timestamp window. - public func findDMMessageForReaction( + /// Fetches channel message candidates within a timestamp window for meshcore-open reaction matching. + /// + /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. + public func fetchChannelMessageCandidates( deviceID: UUID, - contactID: UUID, - messageHash: String, + channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int - ) throws -> MessageDTO? { - let logger = Logger(subsystem: "PocketMeshServices", category: "PersistenceStore") - logger.debug("[DM-REACTION-MATCH] Looking for DM: hash=\(messageHash), contactID=\(contactID)") + ) throws -> [MessageDTO] { + let targetDeviceID = deviceID + let targetChannelIndex: UInt8? = channelIndex + let start = timestampWindow.lowerBound + let end = timestampWindow.upperBound + let predicate = #Predicate { message in + message.deviceID == targetDeviceID && + message.channelIndex == targetChannelIndex && + message.timestamp >= start && + message.timestamp <= end + } + + var descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\Message.createdAt, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse) + ] + ) + descriptor.fetchLimit = limit + + return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } + } + + /// Fetches DM message candidates within a timestamp window for meshcore-open reaction matching. + /// + /// Returns raw candidates without hash matching — the caller performs Dart hash comparison. + public func fetchDMMessageCandidates( + deviceID: UUID, + contactID: UUID, + timestampWindow: ClosedRange, + limit: Int + ) throws -> [MessageDTO] { let targetDeviceID = deviceID let targetContactID: UUID? = contactID let start = timestampWindow.lowerBound @@ -158,33 +195,50 @@ extension PersistenceStore { var descriptor = FetchDescriptor( predicate: predicate, sortBy: [ - SortDescriptor(\Message.timestamp, order: .reverse), - SortDescriptor(\Message.createdAt, order: .reverse) + SortDescriptor(\Message.createdAt, order: .reverse), + SortDescriptor(\Message.timestamp, order: .reverse) ] ) descriptor.fetchLimit = limit - let candidates = try modelContext.fetch(descriptor) + return try modelContext.fetch(descriptor).map { MessageDTO(from: $0) } + } + + /// Finds a DM message matching a reaction by hash within a timestamp window. + public func findDMMessageForReaction( + deviceID: UUID, + contactID: UUID, + messageHash: String, + timestampWindow: ClosedRange, + limit: Int + ) throws -> MessageDTO? { + let logger = Logger(subsystem: "PocketMeshServices", category: "PersistenceStore") + logger.debug("[DM-REACTION-MATCH] Looking for DM: hash=\(messageHash), contactID=\(contactID)") + + let candidates = try fetchDMMessageCandidates( + deviceID: deviceID, + contactID: contactID, + timestampWindow: timestampWindow, + limit: limit + ) logger.debug("[DM-REACTION-MATCH] Found \(candidates.count) candidates") for candidate in candidates { // Skip messages that are themselves reactions - if ReactionParser.parseDM(candidate.text) != nil { + if ReactionParser.isReactionText(candidate.text, isDM: true) { logger.debug("[DM-REACTION-MATCH] Skipping candidate (is reaction): \(candidate.text.prefix(30))") continue } - // Use senderTimestamp if available (for incoming messages with corrected timestamps) - let reactionTimestamp = candidate.senderTimestamp ?? candidate.timestamp let direction = candidate.direction == .outgoing ? "outgoing" : "incoming" let candidateHash = ReactionParser.generateMessageHash( text: candidate.text, - timestamp: reactionTimestamp + timestamp: candidate.reactionTimestamp ) logger.debug("[DM-REACTION-MATCH] Candidate: direction=\(direction), timestamp=\(candidate.timestamp), senderTimestamp=\(candidate.senderTimestamp ?? 0), hash=\(candidateHash), text=\(candidate.text.prefix(30))") if candidateHash == messageHash { logger.debug("[DM-REACTION-MATCH] Found match: \(candidate.id)") - return MessageDTO(from: candidate) + return candidate } else { logger.debug("[DM-REACTION-MATCH] Hash mismatch: \(candidateHash) != \(messageHash)") } diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/ReactionParser.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/ReactionParser.swift index 4385b3ed..a46679cb 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Services/ReactionParser.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/ReactionParser.swift @@ -102,6 +102,13 @@ public struct ParsedDMReaction: Sendable, Equatable { /// Format: `{emoji}@[{sender}]\nxxxxxxxx` public enum ReactionParser { + /// Returns true if the text matches any known reaction format (PocketMesh or meshcore-open). + public static func isReactionText(_ text: String, isDM: Bool) -> Bool { + if MeshCoreOpenReactionParser.parse(text) != nil { return true } + if MeshCoreOpenReactionParser.parseV1(text) != nil { return true } + return isDM ? parseDM(text) != nil : parse(text) != nil + } + /// Parses reaction text, returns nil if format doesn't match public static func parse(_ text: String) -> ParsedReaction? { // Step 1: Split on last newline to get hash diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/RemoteNodeService.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/RemoteNodeService.swift index 6193c394..1bcc5425 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Services/RemoteNodeService.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/RemoteNodeService.swift @@ -32,7 +32,7 @@ public enum RemoteNodeError: Error, LocalizedError, Sendable { case .permissionDenied: return "Permission denied" case .timeout: - return "Request timed out or incorrect password" + return "Request timed out" case .sessionNotFound: return "Remote node session not found" case .passwordNotFound: @@ -97,12 +97,35 @@ public enum LoginTimeoutConfig { public static func timeout(forPathLength pathLength: UInt8) -> Duration { let base = directTimeout let hopCount = decodePathLen(pathLength)?.hopCount ?? 0 - let additional = Duration.seconds(hopCount * 10) + let additional = perHopTimeout * hopCount let total = base + additional return min(total, maximumTimeout) } } +// MARK: - Remote Timeout Policy + +public enum RemoteOperationTimeoutPolicy { + static let firmwareRoundTripMultiplier = 2 + static let loginMaximum: Duration = .seconds(20) + public static let binaryMaximum: Duration = .seconds(15) + static let cliMaximum: Duration = .seconds(15) + static let fireAndForgetCLI: Duration = .seconds(2) + static let pollInterval: Duration = .milliseconds(500) + + static func firmwareRoundTripTimeout(from sentInfo: MessageSentInfo) -> Duration { + .milliseconds(Int(sentInfo.suggestedTimeoutMs) * firmwareRoundTripMultiplier) + } + + static func loginTimeout(for sentInfo: MessageSentInfo, pathLength: UInt8) -> Duration { + min(max(firmwareRoundTripTimeout(from: sentInfo), LoginTimeoutConfig.timeout(forPathLength: pathLength)), loginMaximum) + } + + static func cliTimeout(for sentInfo: MessageSentInfo, requestedTimeout: Duration) -> Duration { + min(max(requestedTimeout, firmwareRoundTripTimeout(from: sentInfo)), cliMaximum) + } +} + // MARK: - Remote Node Service /// Shared service for remote node operations. @@ -308,6 +331,29 @@ public actor RemoteNodeService { return (nil, matchingIndices.count) } + private func timeInterval(for duration: Duration) -> TimeInterval { + let (seconds, attoseconds) = duration.components + return TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18 + } + + private func cancelPendingLogin(for prefix: Data) { + pendingLoginTimeoutTasks.removeValue(forKey: prefix)?.cancel() + if let continuation = pendingLogins.removeValue(forKey: prefix) { + continuation.resume(throwing: RemoteNodeError.cancelled) + } + } + + private func cancelPendingCLIRequest(for prefix: Data, timestamp: Date) { + guard var requests = pendingCLIRequests[prefix], + let index = requests.firstIndex(where: { $0.timestamp == timestamp }) else { + return + } + + let cancelled = requests.remove(at: index) + pendingCLIRequests[prefix] = requests.isEmpty ? nil : requests + cancelled.continuation.resume(throwing: CancellationError()) + } + // MARK: - Session Management /// Create a session DTO for a contact, optionally preserving data from an existing session. @@ -456,44 +502,46 @@ public actor RemoteNodeService { // Register continuation BEFORE sending to avoid race condition with loginSuccess event let prefixHex = prefix.map { String(format: "%02x", $0) }.joined() logger.info("login: registering pending login for prefix \(prefixHex)") - return try await withCheckedThrowingContinuation { continuation in - pendingLogins[prefix] = continuation + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + pendingLogins[prefix] = continuation - let timeoutTask = Task { [self] in - // Send login via MeshCore session - let sentInfo: MessageSentInfo - do { - sentInfo = try await session.sendLogin(to: remoteSession.publicKey, password: pwd) - } catch { - // Send failed - remove pending and resume with error - pendingLoginTimeoutTasks.removeValue(forKey: prefix) - if let pending = pendingLogins.removeValue(forKey: prefix) { - let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) - pending.resume(throwing: RemoteNodeError.sessionError(meshError)) + let timeoutTask = Task { [self] in + let sentInfo: MessageSentInfo + do { + sentInfo = try await session.sendLogin(to: remoteSession.publicKey, password: pwd) + } catch { + pendingLoginTimeoutTasks.removeValue(forKey: prefix) + if let pending = pendingLogins.removeValue(forKey: prefix) { + let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) + pending.resume(throwing: RemoteNodeError.sessionError(meshError)) + } + return } - return - } - // Send succeeded - use 2x firmware's suggested timeout (round trip) - let timeoutMs = Int(sentInfo.suggestedTimeoutMs) * 2 - let timeout = Duration.milliseconds(timeoutMs) - logger.info("login: send succeeded, starting \(timeout) timeout for prefix \(prefixHex)") + let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: pathLength) + logger.info("login: send succeeded, starting \(timeout) timeout for prefix \(prefixHex)") - // Notify caller of timeout so they can show countdown - if let onTimeoutKnown { - await onTimeoutKnown(timeoutMs / 1000) - } - try? await Task.sleep(for: timeout) - guard !Task.isCancelled else { return } - if let pending = pendingLogins.removeValue(forKey: prefix) { - logger.warning("Login timeout after \(timeout) for session \(sessionID), prefix \(prefixHex)") - pendingLoginTimeoutTasks.removeValue(forKey: prefix) - pending.resume(throwing: RemoteNodeError.timeout) - } else { - logger.info("login: timeout elapsed but continuation already consumed for prefix \(prefixHex)") + if let onTimeoutKnown { + let timeoutSeconds = max(1, Int(timeInterval(for: timeout).rounded(.up))) + await onTimeoutKnown(timeoutSeconds) + } + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + if let pending = pendingLogins.removeValue(forKey: prefix) { + logger.warning("Login timeout after \(timeout) for session \(sessionID), prefix \(prefixHex)") + pendingLoginTimeoutTasks.removeValue(forKey: prefix) + pending.resume(throwing: RemoteNodeError.timeout) + } else { + logger.info("login: timeout elapsed but continuation already consumed for prefix \(prefixHex)") + } } + pendingLoginTimeoutTasks[prefix] = timeoutTask + } + } onCancel: { [weak self] in + Task { [weak self] in + await self?.cancelPendingLogin(for: prefix) } - pendingLoginTimeoutTasks[prefix] = timeoutTask } } @@ -721,7 +769,7 @@ public actor RemoteNodeService { // MARK: - Status /// Request status from a remote node. - public func requestStatus(sessionID: UUID) async throws -> StatusResponse { + public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { throw RemoteNodeError.sessionNotFound } @@ -731,7 +779,12 @@ public actor RemoteNodeService { await auditLogger.logStatusRequest(target: targetType, publicKey: remoteSession.publicKey) do { - return try await session.requestStatus(from: remoteSession.publicKey) + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteStatus") { + try await self.session.requestStatus(from: remoteSession.publicKey) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout } catch let error as MeshCoreError { throw RemoteNodeError.sessionError(error) } @@ -740,7 +793,7 @@ public actor RemoteNodeService { // MARK: - Telemetry /// Request telemetry from a remote node - public func requestTelemetry(sessionID: UUID) async throws -> TelemetryResponse { + public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID) else { throw RemoteNodeError.sessionNotFound } @@ -750,7 +803,12 @@ public actor RemoteNodeService { await auditLogger.logTelemetryRequest(target: targetType, publicKey: remoteSession.publicKey) do { - return try await session.requestTelemetry(from: remoteSession.publicKey) + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteTelemetry") { + try await self.session.requestTelemetry(from: remoteSession.publicKey) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout } catch let error as MeshCoreError { throw RemoteNodeError.sessionError(error) } @@ -762,7 +820,7 @@ public actor RemoteNodeService { /// - Parameters: /// - sessionID: The remote node session ID. /// - command: The CLI command to send. - /// - timeout: Maximum time to wait for response (default 10 seconds). + /// - timeout: Hard maximum time to wait for response (default 10 seconds). /// - Returns: The CLI response text from the remote node. public func sendCLICommand( sessionID: UUID, @@ -783,65 +841,61 @@ public actor RemoteNodeService { let destinationPrefix = Data(remoteSession.publicKey.prefix(6)) let requestTimestamp = Date() - // Register continuation BEFORE sending to avoid race condition - return try await withCheckedThrowingContinuation { continuation in - let request = PendingCLIRequest( - command: command, - continuation: continuation, - timestamp: requestTimestamp - ) - - if pendingCLIRequests[destinationPrefix] == nil { - pendingCLIRequests[destinationPrefix] = [] - } - pendingCLIRequests[destinationPrefix]!.append(request) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let request = PendingCLIRequest( + command: command, + continuation: continuation, + timestamp: requestTimestamp + ) - Task { [self] in - // Send CLI command - do { - _ = try await session.sendCommand(to: remoteSession.publicKey, command: command) - } catch { - // Send failed - remove our specific request and resume with error - if var requests = pendingCLIRequests[destinationPrefix], - let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { - let failed = requests.remove(at: index) - pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests - let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) - failed.continuation.resume(throwing: RemoteNodeError.sessionError(meshError)) - } - return + if pendingCLIRequests[destinationPrefix] == nil { + pendingCLIRequests[destinationPrefix] = [] } + pendingCLIRequests[destinationPrefix]!.append(request) - // Actively poll for response instead of passive wait - // Device may buffer responses without immediately sending messagesWaiting notification - let (seconds, attoseconds) = timeout.components - let deadline = Date().addingTimeInterval(TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18) - while Date() < deadline { - // Check if our specific request was already satisfied - if let requests = pendingCLIRequests[destinationPrefix], - !requests.contains(where: { $0.timestamp == requestTimestamp }) { - return // Our request was matched and removed - } else if pendingCLIRequests[destinationPrefix] == nil { - return // All requests cleared + Task { [self] in + let sentInfo: MessageSentInfo + do { + sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) + } catch { + if var requests = pendingCLIRequests[destinationPrefix], + let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { + let failed = requests.remove(at: index) + pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests + let meshError = error as? MeshCoreError ?? MeshCoreError.connectionLost(underlying: error) + failed.continuation.resume(throwing: RemoteNodeError.sessionError(meshError)) + } + return } - // Poll device for pending messages - // This triggers message delivery through the event dispatcher, - // which will call our handleCLIResponse() if a CLI response arrives - _ = try? await session.getMessage() + let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) + let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) + while ContinuousClock.now < deadline { + if let requests = pendingCLIRequests[destinationPrefix], + !requests.contains(where: { $0.timestamp == requestTimestamp }) { + return + } else if pendingCLIRequests[destinationPrefix] == nil { + return + } - // Small delay between polls - try? await Task.sleep(for: .milliseconds(500)) - } + let remaining = deadline - .now + let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) + _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) + } - // Timeout - remove our specific request and resume with error - if var requests = pendingCLIRequests[destinationPrefix], - let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { - let timedOut = requests.remove(at: index) - pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests - timedOut.continuation.resume(throwing: RemoteNodeError.timeout) + if var requests = pendingCLIRequests[destinationPrefix], + let index = requests.firstIndex(where: { $0.timestamp == requestTimestamp }) { + let timedOut = requests.remove(at: index) + pendingCLIRequests[destinationPrefix] = requests.isEmpty ? nil : requests + timedOut.continuation.resume(throwing: RemoteNodeError.timeout) + } } } + } onCancel: { [weak self] in + Task { [weak self] in + await self?.cancelPendingCLIRequest(for: destinationPrefix, timestamp: requestTimestamp) + } } } @@ -851,7 +905,7 @@ public actor RemoteNodeService { /// - Parameters: /// - sessionID: The remote node session ID. /// - command: The CLI command to send. - /// - timeout: Maximum time to wait for response (default 10 seconds). + /// - timeout: Hard maximum time to wait for response (default 10 seconds). /// - Returns: The raw response text from the remote node. public func sendRawCLICommand( sessionID: UUID, @@ -884,8 +938,9 @@ public actor RemoteNodeService { Task { [self] in // Send CLI command + let sentInfo: MessageSentInfo do { - _ = try await session.sendCommand(to: remoteSession.publicKey, command: command) + sentInfo = try await session.sendCommand(to: remoteSession.publicKey, command: command) } catch { // Send failed - remove pending request and resume with error if let pending = pendingRawCLIRequests.removeValue(forKey: destinationPrefix) { @@ -895,10 +950,11 @@ public actor RemoteNodeService { return } + let effectiveTimeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: timeout) + // Poll for response - let (seconds, attoseconds) = timeout.components - let deadline = Date().addingTimeInterval(TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18) - while Date() < deadline { + let deadline = ContinuousClock.now.advanced(by: effectiveTimeout) + while ContinuousClock.now < deadline { // Check if our request was already satisfied guard pendingRawCLIRequests[destinationPrefix] != nil else { return // Request was matched and removed by handleCLIResponse @@ -913,10 +969,9 @@ public actor RemoteNodeService { } // Poll device for pending messages - _ = try? await session.getMessage() - - // Small delay between polls - try? await Task.sleep(for: .milliseconds(500)) + let remaining = deadline - .now + let pollDuration = min(RemoteOperationTimeoutPolicy.pollInterval, remaining) + _ = try? await session.getMessage(timeout: max(0.1, timeInterval(for: pollDuration))) } // Timeout - remove pending request and resume with error diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/RepeaterAdminService.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/RepeaterAdminService.swift index c8091305..b0dc8578 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Services/RepeaterAdminService.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/RepeaterAdminService.swift @@ -105,7 +105,8 @@ public actor RepeaterAdminService { count: UInt8 = 20, offset: UInt16 = 0, orderBy: NeighborSortOrder = .newestFirst, - pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength + pubkeyPrefixLength: UInt8 = defaultPubkeyPrefixLength, + timeout: Duration? = nil ) async throws -> NeighboursResponse { guard let remoteSession = try await dataStore.fetchRemoteNodeSession(id: sessionID), remoteSession.isRepeater else { @@ -116,13 +117,18 @@ public actor RepeaterAdminService { await auditLogger.logNeighborsRequest(publicKey: remoteSession.publicKey, count: count, offset: offset) do { - return try await session.requestNeighbours( - from: remoteSession.publicKey, - count: count, - offset: offset, - orderBy: orderBy.rawValue, - pubkeyPrefixLength: pubkeyPrefixLength - ) + let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum + return try await withTimeout(effectiveTimeout, operationName: "remoteNeighbours") { + try await self.session.requestNeighbours( + from: remoteSession.publicKey, + count: count, + offset: offset, + orderBy: orderBy.rawValue, + pubkeyPrefixLength: pubkeyPrefixLength + ) + } + } catch is TimeoutError { + throw RemoteNodeError.timeout } catch let error as MeshCoreError { throw RemoteNodeError.sessionError(error) } @@ -153,15 +159,15 @@ public actor RepeaterAdminService { // MARK: - Status /// Request status from a repeater. - public func requestStatus(sessionID: UUID) async throws -> StatusResponse { - try await remoteNodeService.requestStatus(sessionID: sessionID) + public func requestStatus(sessionID: UUID, timeout: Duration? = nil) async throws -> StatusResponse { + try await remoteNodeService.requestStatus(sessionID: sessionID, timeout: timeout) } // MARK: - Telemetry /// Request telemetry from a repeater. - public func requestTelemetry(sessionID: UUID) async throws -> TelemetryResponse { - try await remoteNodeService.requestTelemetry(sessionID: sessionID) + public func requestTelemetry(sessionID: UUID, timeout: Duration? = nil) async throws -> TelemetryResponse { + try await remoteNodeService.requestTelemetry(sessionID: sessionID, timeout: timeout) } // MARK: - CLI Commands diff --git a/PocketMeshServices/Sources/PocketMeshServices/Services/SettingsService.swift b/PocketMeshServices/Sources/PocketMeshServices/Services/SettingsService.swift index 43c882b1..e3571636 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Services/SettingsService.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Services/SettingsService.swift @@ -10,6 +10,7 @@ public enum SettingsServiceError: Error, LocalizedError, Sendable { case invalidResponse case sessionError(MeshCoreError) case verificationFailed(expected: String, actual: String) + case deviceGPSVerificationFailed(expectedEnabled: Bool, actualEnabled: Bool) public var errorDescription: String? { switch self { @@ -19,6 +20,10 @@ public enum SettingsServiceError: Error, LocalizedError, Sendable { case .sessionError(let error): return error.localizedDescription case .verificationFailed(let expected, let actual): return "Setting was not saved. Expected '\(expected)' but device reports '\(actual)'." + case .deviceGPSVerificationFailed(let expectedEnabled, let actualEnabled): + let expected = expectedEnabled ? "On" : "Off" + let actual = actualEnabled ? "On" : "Off" + return "Device GPS setting was not saved. Expected '\(expected)' but device reports '\(actual)'." } } @@ -255,12 +260,35 @@ public struct TelemetryModes: Sendable, Equatable { } } +// MARK: - Advert Location Policy + +/// Location inclusion policy for advertisements. +public enum AdvertLocationPolicy: UInt8, Sendable, CaseIterable { + case none = 0 + case share = 1 + case prefs = 2 + + public var isEnabled: Bool { + self != .none + } +} + +public struct DeviceGPSState: Sendable, Equatable { + public let isSupported: Bool + public let isEnabled: Bool + + public init(isSupported: Bool, isEnabled: Bool) { + self.isSupported = isSupported + self.isEnabled = isEnabled + } +} + // MARK: - Settings Events /// Events emitted by SettingsService when device settings change. public enum SettingsEvent: Sendable { case deviceUpdated(MeshCore.SelfInfo) - case autoAddConfigUpdated(UInt8) + case autoAddConfigUpdated(MeshCore.AutoAddConfig) case clientRepeatUpdated(Bool) case pathHashModeUpdated(UInt8) case allowedRepeatFreqUpdated([MeshCore.FrequencyRange]) @@ -383,7 +411,7 @@ public actor SettingsService { public func setOtherParams( autoAddContacts: Bool, telemetryModes: TelemetryModes, - shareLocationPublicly: Bool, + advertLocationPolicy: AdvertLocationPolicy, multiAcks: UInt8 ) async throws { do { @@ -392,7 +420,7 @@ public actor SettingsService { telemetryModeEnvironment: telemetryModes.environment, telemetryModeLocation: telemetryModes.location, telemetryModeBase: telemetryModes.base, - advertisementLocationPolicy: shareLocationPublicly ? 1 : 0, + advertisementLocationPolicy: advertLocationPolicy.rawValue, multiAcks: multiAcks ) } catch let error as MeshCoreError { @@ -400,6 +428,22 @@ public actor SettingsService { } } + /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. + @available(*, deprecated, message: "Use advertLocationPolicy overload instead") + public func setOtherParams( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + shareLocationPublicly: Bool, + multiAcks: UInt8 + ) async throws { + try await setOtherParams( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: shareLocationPublicly ? .prefs : .none, + multiAcks: multiAcks + ) + } + // MARK: - Factory Reset /// Perform factory reset on device @@ -477,6 +521,10 @@ public actor SettingsService { // Calculate the scaled values we're actually sending let scaledLatSent = Int32(latitude * 1_000_000) let scaledLonSent = Int32(longitude * 1_000_000) + + // log when attempting to clear location + let isClearingLocation = scaledLatSent == 0 && scaledLonSent == 0 + logger.debug("[Location] setLocationVerified called - lat: \(latitude), lon: \(longitude), isClearing: \(isClearingLocation)") try await setLocation(latitude: latitude, longitude: longitude) @@ -493,6 +541,10 @@ public actor SettingsService { guard latDiff <= tolerance && lonDiff <= tolerance else { logger.error("[Location] Verification failed - sent: (\(scaledLatSent), \(scaledLonSent)), received: (\(scaledLatReceived), \(scaledLonReceived)), diff: (lat=\(latDiff), lon=\(lonDiff))") + + if isClearingLocation { + logger.warning("[Location] Clear location failed - device reports non-zero coordinates. Device may have active GPS or firmware doesn't support (0,0).") + } let expectedLat = Double(scaledLatSent) / 1_000_000 let expectedLon = Double(scaledLonSent) / 1_000_000 @@ -506,6 +558,15 @@ public actor SettingsService { return selfInfo } + /// Set a manual location, turning off device GPS first when needed so the value persists. + public func setManualLocationVerified(latitude: Double, longitude: Double) async throws -> MeshCore.SelfInfo { + let gpsState = try await getDeviceGPSState() + if gpsState.isSupported, gpsState.isEnabled { + _ = try await setDeviceGPSEnabledVerified(false) + } + return try await setLocationVerified(latitude: latitude, longitude: longitude) + } + /// Set radio parameters with verification public func setRadioParamsVerified( frequencyKHz: UInt32, @@ -596,13 +657,13 @@ public actor SettingsService { public func setOtherParamsVerified( autoAddContacts: Bool, telemetryModes: TelemetryModes, - shareLocationPublicly: Bool, + advertLocationPolicy: AdvertLocationPolicy, multiAcks: UInt8 ) async throws -> MeshCore.SelfInfo { try await setOtherParams( autoAddContacts: autoAddContacts, telemetryModes: telemetryModes, - shareLocationPublicly: shareLocationPublicly, + advertLocationPolicy: advertLocationPolicy, multiAcks: multiAcks ) @@ -620,10 +681,26 @@ public actor SettingsService { return selfInfo } + /// Compatibility overload: map boolean sharing to `prefs` policy when enabled. + @available(*, deprecated, message: "Use advertLocationPolicy overload instead") + public func setOtherParamsVerified( + autoAddContacts: Bool, + telemetryModes: TelemetryModes, + shareLocationPublicly: Bool, + multiAcks: UInt8 + ) async throws -> MeshCore.SelfInfo { + try await setOtherParamsVerified( + autoAddContacts: autoAddContacts, + telemetryModes: telemetryModes, + advertLocationPolicy: shareLocationPublicly ? .prefs : .none, + multiAcks: multiAcks + ) + } + // MARK: - Auto-Add Config /// Get auto-add configuration from device - public func getAutoAddConfig() async throws -> UInt8 { + public func getAutoAddConfig() async throws -> MeshCore.AutoAddConfig { do { return try await session.getAutoAddConfig() } catch let error as MeshCoreError { @@ -663,7 +740,7 @@ public actor SettingsService { } /// Set auto-add configuration on device - public func setAutoAddConfig(_ config: UInt8) async throws { + public func setAutoAddConfig(_ config: MeshCore.AutoAddConfig) async throws { do { try await session.setAutoAddConfig(config) } catch let error as MeshCoreError { @@ -672,15 +749,15 @@ public actor SettingsService { } /// Set auto-add configuration with verification - public func setAutoAddConfigVerified(_ config: UInt8) async throws -> UInt8 { + public func setAutoAddConfigVerified(_ config: MeshCore.AutoAddConfig) async throws -> MeshCore.AutoAddConfig { try await setAutoAddConfig(config) let actualConfig = try await getAutoAddConfig() guard actualConfig == config else { throw SettingsServiceError.verificationFailed( - expected: "config=\(config)", - actual: "config=\(actualConfig)" + expected: "bitmask=\(config.bitmask), maxHops=\(config.maxHops)", + actual: "bitmask=\(actualConfig.bitmask), maxHops=\(actualConfig.maxHops)" ) } @@ -760,6 +837,11 @@ public actor SettingsService { } } + public func getDeviceGPSState() async throws -> DeviceGPSState { + let vars = try await getCustomVars() + return Self.deviceGPSState(from: vars) + } + /// Set a custom variable on device public func setCustomVar(key: String, value: String) async throws { do { @@ -769,6 +851,27 @@ public actor SettingsService { } } + public func setDeviceGPSEnabledVerified(_ enabled: Bool) async throws -> DeviceGPSState { + try await setCustomVar(key: "gps", value: enabled ? "1" : "0") + + let state = try await getDeviceGPSState() + guard state.isSupported else { + throw SettingsServiceError.deviceGPSVerificationFailed( + expectedEnabled: enabled, + actualEnabled: false + ) + } + guard state.isEnabled == enabled else { + throw SettingsServiceError.deviceGPSVerificationFailed( + expectedEnabled: enabled, + actualEnabled: state.isEnabled + ) + } + + try await refreshDeviceInfo() + return state + } + // MARK: - Private Key Management /// Export private key from device @@ -799,4 +902,11 @@ public actor SettingsService { throw SettingsServiceError.sessionError(error) } } + + private static func deviceGPSState(from vars: [String: String]) -> DeviceGPSState { + guard let value = vars["gps"] else { + return DeviceGPSState(isSupported: false, isEnabled: false) + } + return DeviceGPSState(isSupported: true, isEnabled: value == "1") + } } diff --git a/PocketMeshServices/Sources/PocketMeshServices/SyncCoordinator+MessageHandlers.swift b/PocketMeshServices/Sources/PocketMeshServices/SyncCoordinator+MessageHandlers.swift index a0baa0a7..8c7de5b7 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/SyncCoordinator+MessageHandlers.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/SyncCoordinator+MessageHandlers.swift @@ -479,6 +479,28 @@ extension SyncCoordinator { deviceID: UUID, services: ServiceContainer ) async -> Bool { + // Try meshcore-open v3 format + if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { + return await handleMCODMReaction( + mcoReaction, + rawText: text, + contact: contact, + deviceID: deviceID, + services: services + ) + } + + // Try meshcore-open v1 format + if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { + return await handleMCOV1DMReaction( + v1Reaction, + rawText: text, + contact: contact, + deviceID: deviceID, + services: services + ) + } + guard let parsed = ReactionParser.parseDM(text) else { return false } // Try to find target in cache first @@ -503,15 +525,13 @@ extension SyncCoordinator { } // Try persistence fallback - let now = UInt32(Date().timeIntervalSince1970) - let windowStart = now > reactionTimestampWindowSeconds ? now - reactionTimestampWindowSeconds : 0 - let windowEnd = now + reactionTimestampWindowSeconds + let timestampWindow = reactionTimestampWindow() if let targetMessage = try? await services.dataStore.findDMMessageForReaction( deviceID: deviceID, contactID: contact.id, messageHash: parsed.messageHash, - timestampWindow: windowStart...windowEnd, + timestampWindow: timestampWindow, limit: 200 ) { let reactionDTO = ReactionDTO( @@ -555,6 +575,33 @@ extension SyncCoordinator { deviceID: UUID, services: ServiceContainer ) async -> Bool { + // Try meshcore-open v3 format + if let mcoReaction = MeshCoreOpenReactionParser.parse(text) { + return await handleMCOChannelReaction( + mcoReaction, + rawText: text, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + selfNodeName: selfNodeName, + receiveTime: receiveTime, + deviceID: deviceID, + services: services + ) + } + + // Try meshcore-open v1 format + if let v1Reaction = MeshCoreOpenReactionParser.parseV1(text) { + return await handleMCOV1ChannelReaction( + v1Reaction, + rawText: text, + channelIndex: channelIndex, + senderNodeName: senderNodeName, + selfNodeName: selfNodeName, + deviceID: deviceID, + services: services + ) + } + guard let parsed = services.reactionService.tryProcessAsReaction(text) else { return false } let senderName = senderNodeName ?? "Unknown" @@ -579,9 +626,7 @@ extension SyncCoordinator { return true } - let now = UInt32(receiveTime.timeIntervalSince1970) - let windowStart = now > reactionTimestampWindowSeconds ? now - reactionTimestampWindowSeconds : 0 - let windowEnd = now + reactionTimestampWindowSeconds + let timestampWindow = reactionTimestampWindow(at: receiveTime) logger.debug("DB lookup: selfNodeName='\(selfNodeName)', targetSender=\(parsed.targetSender), hash=\(parsed.messageHash)") @@ -590,7 +635,7 @@ extension SyncCoordinator { channelIndex: channelIndex, parsedReaction: parsed, localNodeName: selfNodeName.isEmpty ? nil : selfNodeName, - timestampWindow: windowStart...windowEnd, + timestampWindow: timestampWindow, limit: 200 ) { let targetMessageID = targetMessage.id @@ -748,6 +793,250 @@ extension SyncCoordinator { lastUnresolvedChannelSummaryAt = now } + /// Computes a symmetric timestamp window around the given time for reaction matching. + private func reactionTimestampWindow(at time: Date = Date()) -> ClosedRange { + reactionTimestampWindow(anchor: UInt32(time.timeIntervalSince1970)) + } + + /// Computes a symmetric timestamp window around a specific anchor timestamp. + private func reactionTimestampWindow(anchor: UInt32) -> ClosedRange { + let start = anchor > reactionTimestampWindowSeconds ? anchor - reactionTimestampWindowSeconds : 0 + return start...(anchor + reactionTimestampWindowSeconds) + } + + // MARK: - meshcore-open Reaction Handlers + + /// Handles a meshcore-open DM reaction by computing Dart hashes against DB candidates. + /// + /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. + private func handleMCODMReaction( + _ mcoReaction: ParsedMCOReaction, + rawText: String, + contact: ContactDTO, + deviceID: UUID, + services: ServiceContainer + ) async -> Bool { + let timestampWindow = reactionTimestampWindow() + + guard let candidates = try? await services.dataStore.fetchDMMessageCandidates( + deviceID: deviceID, + contactID: contact.id, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO DM reaction \(mcoReaction.emoji): no candidates in window") + return true + } + + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } + + let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: candidate.reactionTimestamp, + senderName: nil, + text: candidate.text + ) + + guard candidateHash == mcoReaction.dartHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: mcoReaction.emoji, + senderName: contact.displayName, + messageHash: mcoReaction.dartHash, + rawText: rawText, + contactID: contact.id, + deviceID: deviceID + ) + if await persistReactionIfNew(reactionDTO, services: services) { + logger.debug("Saved MCO DM reaction \(mcoReaction.emoji) to message \(candidate.id)") + } + return true + } + + logger.debug("MCO DM reaction \(mcoReaction.emoji): no hash match found") + return true + } + + /// Handles a meshcore-open channel reaction by computing Dart hashes against DB candidates. + /// + /// No LRU cache or pending queue — if no match is found, the reaction is silently dropped. + private func handleMCOChannelReaction( + _ mcoReaction: ParsedMCOReaction, + rawText: String, + channelIndex: UInt8, + senderNodeName: String?, + selfNodeName: String, + receiveTime: Date, + deviceID: UUID, + services: ServiceContainer + ) async -> Bool { + let senderName = senderNodeName ?? "Unknown" + let timestampWindow = reactionTimestampWindow(at: receiveTime) + + guard let candidates = try? await services.dataStore.fetchChannelMessageCandidates( + deviceID: deviceID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO channel reaction \(mcoReaction.emoji): no candidates in window") + return true + } + + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } + + // For channel messages, the Dart hash includes the sender name + let candidateSenderName: String? + if candidate.direction == .outgoing { + candidateSenderName = selfNodeName.isEmpty ? nil : selfNodeName + } else { + candidateSenderName = candidate.senderNodeName + } + + let candidateHash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: candidate.reactionTimestamp, + senderName: candidateSenderName, + text: candidate.text + ) + + guard candidateHash == mcoReaction.dartHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: mcoReaction.emoji, + senderName: senderName, + messageHash: mcoReaction.dartHash, + rawText: rawText, + channelIndex: channelIndex, + deviceID: deviceID + ) + if await persistReactionIfNew(reactionDTO, services: services) { + logger.debug("Saved MCO channel reaction \(mcoReaction.emoji) to message \(candidate.id)") + } + return true + } + + logger.debug("MCO channel reaction \(mcoReaction.emoji): no hash match found") + return true + } + + // MARK: - meshcore-open V1 Reaction Handlers + + /// Handles a meshcore-open v1 DM reaction by matching timestamp + Dart text hash. + private func handleMCOV1DMReaction( + _ v1Reaction: ParsedMCOReactionV1, + rawText: String, + contact: ContactDTO, + deviceID: UUID, + services: ServiceContainer + ) async -> Bool { + let timestampWindow = reactionTimestampWindow( + anchor: v1Reaction.timestampSeconds + ) + + guard let candidates = try? await services.dataStore.fetchDMMessageCandidates( + deviceID: deviceID, + contactID: contact.id, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no candidates in window") + return true + } + + for candidate in candidates { + if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } + + let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) + guard textHash == v1Reaction.textHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: v1Reaction.emoji, + senderName: contact.displayName, + messageHash: v1Reaction.messageIdHash, + rawText: rawText, + contactID: contact.id, + deviceID: deviceID + ) + if await persistReactionIfNew(reactionDTO, services: services) { + logger.debug("Saved MCO v1 DM reaction \(v1Reaction.emoji) to message \(candidate.id)") + } + return true + } + + logger.debug("MCO v1 DM reaction \(v1Reaction.emoji): no hash match found") + return true + } + + /// Handles a meshcore-open v1 channel reaction by matching timestamp + Dart sender/text hashes. + private func handleMCOV1ChannelReaction( + _ v1Reaction: ParsedMCOReactionV1, + rawText: String, + channelIndex: UInt8, + senderNodeName: String?, + selfNodeName: String, + deviceID: UUID, + services: ServiceContainer + ) async -> Bool { + let senderName = senderNodeName ?? "Unknown" + let timestampWindow = reactionTimestampWindow( + anchor: v1Reaction.timestampSeconds + ) + + guard let candidates = try? await services.dataStore.fetchChannelMessageCandidates( + deviceID: deviceID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: 200 + ), !candidates.isEmpty else { + logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no candidates in window") + return true + } + + for candidate in candidates { + if ReactionParser.isReactionText(candidate.text, isDM: false) { continue } + + // Verify sender name hash + let candidateSenderName: String? + if candidate.direction == .outgoing { + candidateSenderName = selfNodeName.isEmpty ? nil : selfNodeName + } else { + candidateSenderName = candidate.senderNodeName + } + + if let name = candidateSenderName { + let nameHash = MeshCoreOpenReactionParser.dartStringHash(name) + guard nameHash == v1Reaction.senderNameHash else { continue } + } + + // Verify text hash + let textHash = MeshCoreOpenReactionParser.dartStringHash(candidate.text) + guard textHash == v1Reaction.textHash else { continue } + + let reactionDTO = ReactionDTO( + messageID: candidate.id, + emoji: v1Reaction.emoji, + senderName: senderName, + messageHash: v1Reaction.messageIdHash, + rawText: rawText, + channelIndex: channelIndex, + deviceID: deviceID + ) + if await persistReactionIfNew(reactionDTO, services: services) { + logger.debug("Saved MCO v1 channel reaction \(v1Reaction.emoji) to message \(candidate.id)") + } + return true + } + + logger.debug("MCO v1 channel reaction \(v1Reaction.emoji): no hash match found") + return true + } + nonisolated static func parseChannelMessage(_ text: String) -> (senderNodeName: String?, messageText: String) { let parts = text.split(separator: ":", maxSplits: 1) if parts.count > 1 { diff --git a/PocketMeshServices/Sources/PocketMeshServices/Transport/BLEStateMachine.swift b/PocketMeshServices/Sources/PocketMeshServices/Transport/BLEStateMachine.swift index d57fc6f6..a7f76bff 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Transport/BLEStateMachine.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Transport/BLEStateMachine.swift @@ -297,6 +297,13 @@ public actor BLEStateMachine: BLEStateMachineProtocol { return connectedPeripherals.contains { $0.identifier == deviceID } } + public func systemConnectedPeripheralIDs() -> [UUID] { + activate() + return centralManager.retrieveConnectedPeripherals( + withServices: [nordicUARTServiceUUID] + ).map(\.identifier) + } + /// Starts a best-effort adoption of an already system-connected peripheral. /// /// When iOS keeps the BLE link alive but state restoration does not fire (common across app updates), diff --git a/PocketMeshServices/Sources/PocketMeshServices/Transport/BLEStateMachineProtocol.swift b/PocketMeshServices/Sources/PocketMeshServices/Transport/BLEStateMachineProtocol.swift index cd43c214..ff10a9e2 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Transport/BLEStateMachineProtocol.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Transport/BLEStateMachineProtocol.swift @@ -37,6 +37,10 @@ public protocol BLEStateMachineProtocol: Actor { /// - Returns: `true` if the device is connected to the system func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool + /// Returns the UUIDs of all peripherals currently connected to the system via Nordic UART. + /// Used for diagnostics — exposes the raw `retrieveConnectedPeripherals` result. + func systemConnectedPeripheralIDs() -> [UUID] + /// Starts a best-effort adoption of an already system-connected peripheral. /// /// This is used to recover from cases where iOS keeps the BLE link alive across app termination diff --git a/PocketMeshServices/Sources/PocketMeshServices/Utilities/HashtagUtilities.swift b/PocketMeshServices/Sources/PocketMeshServices/Utilities/HashtagUtilities.swift index 29cb6740..253b9517 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Utilities/HashtagUtilities.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Utilities/HashtagUtilities.swift @@ -5,6 +5,11 @@ public enum HashtagUtilities { public static let hashtagPattern = "#[A-Za-z0-9][A-Za-z0-9-]*" + /// Pre-compiled regex for hashtag matching (avoids recompilation per call) + public static let hashtagRegex: NSRegularExpression? = { + try? NSRegularExpression(pattern: hashtagPattern) + }() + /// Represents a detected hashtag with its location in the source text public struct DetectedHashtag: Equatable, Sendable { public let name: String @@ -20,15 +25,21 @@ public enum HashtagUtilities { /// - Parameter text: The message text to search /// - Returns: Array of detected hashtags with their ranges public static func extractHashtags(from text: String) -> [DetectedHashtag] { - guard !text.isEmpty else { return [] } + extractHashtags(from: text, urlRanges: findURLRanges(in: text)) + } - // First, find all URL ranges to exclude - let urlRanges = findURLRanges(in: text) + /// Extracts all valid hashtags from text, using pre-computed URL ranges to skip + /// - Parameters: + /// - text: The message text to search + /// - urlRanges: Pre-computed URL ranges (avoids duplicate NSDataDetector scan) + /// - Returns: Array of detected hashtags with their ranges + public static func extractHashtags( + from text: String, + urlRanges: [Range] + ) -> [DetectedHashtag] { + guard !text.isEmpty else { return [] } - // Find all hashtag matches - guard let regex = try? NSRegularExpression(pattern: hashtagPattern) else { - return [] - } + guard let regex = hashtagRegex else { return [] } let nsRange = NSRange(text.startIndex..., in: text) let matches = regex.matches(in: text, range: nsRange) @@ -37,14 +48,8 @@ public enum HashtagUtilities { guard let range = Range(match.range, in: text) else { return nil } // Skip hashtags that fall within URL ranges - let matchStart = text.distance(from: text.startIndex, to: range.lowerBound) - let matchEnd = text.distance(from: text.startIndex, to: range.upperBound) - for urlRange in urlRanges { - let urlStart = text.distance(from: text.startIndex, to: urlRange.lowerBound) - let urlEnd = text.distance(from: text.startIndex, to: urlRange.upperBound) - - if matchStart >= urlStart && matchEnd <= urlEnd { + if range.lowerBound >= urlRange.lowerBound && range.upperBound <= urlRange.upperBound { return nil } } diff --git a/PocketMeshServices/Sources/PocketMeshServices/Utilities/MentionUtilities.swift b/PocketMeshServices/Sources/PocketMeshServices/Utilities/MentionUtilities.swift index 619b0b7d..4b915fc3 100644 --- a/PocketMeshServices/Sources/PocketMeshServices/Utilities/MentionUtilities.swift +++ b/PocketMeshServices/Sources/PocketMeshServices/Utilities/MentionUtilities.swift @@ -5,6 +5,11 @@ public enum MentionUtilities { /// The regex pattern for matching mentions: @[name] public static let mentionPattern = #"@\[([^\]]+)\]"# + /// Pre-compiled regex for mention matching (avoids recompilation per call) + public static let mentionRegex: NSRegularExpression? = { + try? NSRegularExpression(pattern: mentionPattern) + }() + /// Creates a mention string from a node contact name /// - Parameter name: The mesh network contact name (not nickname) /// - Returns: Formatted mention string "@[name]" @@ -16,9 +21,7 @@ public enum MentionUtilities { /// - Parameter text: The message text to parse /// - Returns: Array of mentioned contact names (without @[] wrapper) public static func extractMentions(from text: String) -> [String] { - guard let regex = try? NSRegularExpression(pattern: mentionPattern) else { - return [] - } + guard let regex = mentionRegex else { return [] } let range = NSRange(text.startIndex..., in: text) let matches = regex.matches(in: text, range: range) @@ -137,12 +140,19 @@ public enum MentionUtilities { return mentions.contains { $0.caseInsensitiveCompare(selfName) == .orderedSame } } + /// Pre-compiled regex for stripping a leading mention from reply text + private static let leadingMentionRegex: NSRegularExpression? = { + try? NSRegularExpression(pattern: #"^@\[[^\]]+\]\s*"#) + }() + /// Builds reply text with a mention and quoted preview of the original message. /// Strips any leading mention from the message text before generating the preview. public static func buildReplyText(mentionName: String, messageText: String) -> String { let previewSource: String - if let range = messageText.range(of: #"^@\[[^\]]+\]\s*"#, options: .regularExpression) { - previewSource = String(messageText[range.upperBound...]) + if let regex = leadingMentionRegex, + let match = regex.firstMatch(in: messageText, range: NSRange(messageText.startIndex..., in: messageText)), + let matchRange = Range(match.range, in: messageText) { + previewSource = String(messageText[matchRange.upperBound...]) } else { previewSource = messageText } diff --git a/PocketMeshServices/Tests/PocketMeshServicesTests/ConnectionManagerPairingTests.swift b/PocketMeshServices/Tests/PocketMeshServicesTests/ConnectionManagerPairingTests.swift index 33a67cb9..85fd2a8a 100644 --- a/PocketMeshServices/Tests/PocketMeshServicesTests/ConnectionManagerPairingTests.swift +++ b/PocketMeshServices/Tests/PocketMeshServicesTests/ConnectionManagerPairingTests.swift @@ -1,7 +1,21 @@ import Foundation +import os import Testing @testable import PocketMeshServices +/// Thread-safe counter for tracking call counts in mock handlers. +private final class Counter: Sendable { + private let lock = OSAllocatedUnfairLock(initialState: 0) + + /// Increments the counter and returns the new value. + func increment() -> Int { + lock.withLock { value in + value += 1 + return value + } + } +} + @Suite("ConnectionManager Pairing Tests") @MainActor struct ConnectionManagerPairingTests { @@ -63,16 +77,17 @@ struct ConnectionManagerPairingTests { let device = DeviceDTO.testDevice() manager.updateDevice(with: device) - manager.updateAutoAddConfig(5) + manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) #expect(manager.connectedDevice?.autoAddConfig == 5) + #expect(manager.connectedDevice?.autoAddMaxHops == 3) } @Test("updateAutoAddConfig does nothing when not connected") func updateAutoAddConfigWhenDisconnected() throws { let (manager, _) = try ConnectionManager.createForTesting() - manager.updateAutoAddConfig(5) + manager.updateAutoAddConfig(AutoAddConfig(bitmask: 5, maxHops: 3)) #expect(manager.connectedDevice == nil) } @@ -135,6 +150,54 @@ struct ConnectionManagerPairingTests { #expect(afterSave != afterClear) } + // MARK: - Other-App Reconnection Polling + + @Test("waitForOtherAppReconnection returns true on immediate detection") + func waitForOtherAppReconnectionImmediate() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsDeviceConnectedToSystem(true) + + let result = await manager.waitForOtherAppReconnection(deviceID) + + #expect(result == true) + let callCount = await mock.isDeviceConnectedToSystemCalls.count + #expect(callCount == 1) + } + + @Test("waitForOtherAppReconnection returns false after all checks") + func waitForOtherAppReconnectionNoOtherApp() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + await mock.setStubbedIsDeviceConnectedToSystem(false) + + let result = await manager.waitForOtherAppReconnection(deviceID) + + #expect(result == false) + let callCount = await mock.isDeviceConnectedToSystemCalls.count + #expect(callCount == 6) + } + + @Test("waitForOtherAppReconnection detects delayed reconnection") + func waitForOtherAppReconnectionDelayed() async throws { + let (manager, mock) = try ConnectionManager.createForTesting() + let deviceID = UUID() + + // Return true on the 3rd call using a counter outside the actor + let callCounter = Counter() + await mock.setIsDeviceConnectedToSystemHandler { _ in + return callCounter.increment() >= 3 + } + + let result = await manager.waitForOtherAppReconnection(deviceID) + + #expect(result == true) + let callCount = await mock.isDeviceConnectedToSystemCalls.count + #expect(callCount == 3) + } + // MARK: - Data Operations @Test("fetchSavedDevices returns empty array when no devices saved") diff --git a/PocketMeshServices/Tests/PocketMeshServicesTests/Mocks/MockBLEStateMachine.swift b/PocketMeshServices/Tests/PocketMeshServicesTests/Mocks/MockBLEStateMachine.swift index 208a9f24..77989e32 100644 --- a/PocketMeshServices/Tests/PocketMeshServicesTests/Mocks/MockBLEStateMachine.swift +++ b/PocketMeshServices/Tests/PocketMeshServicesTests/Mocks/MockBLEStateMachine.swift @@ -16,6 +16,7 @@ public actor MockBLEStateMachine: BLEStateMachineProtocol { public var stubbedCentralManagerStateName: String = "poweredOn" public var stubbedIsBluetoothPoweredOff: Bool = false public var stubbedIsDeviceConnectedToSystem: Bool = false + public var isDeviceConnectedToSystemHandler: (@Sendable (UUID) -> Bool)? public var stubbedDidStartAdoptingSystemConnectedPeripheral: Bool = false // MARK: - Protocol Properties @@ -53,9 +54,16 @@ public actor MockBLEStateMachine: BLEStateMachineProtocol { public func isDeviceConnectedToSystem(_ deviceID: UUID) -> Bool { isDeviceConnectedToSystemCalls.append(deviceID) + if let handler = isDeviceConnectedToSystemHandler { + return handler(deviceID) + } return stubbedIsDeviceConnectedToSystem } + public func systemConnectedPeripheralIDs() -> [UUID] { + [] + } + public func startAdoptingSystemConnectedPeripheral(_ deviceID: UUID) -> Bool { startAdoptingSystemConnectedPeripheralCalls.append(deviceID) return stubbedDidStartAdoptingSystemConnectedPeripheral @@ -123,6 +131,7 @@ public actor MockBLEStateMachine: BLEStateMachineProtocol { stubbedCentralManagerStateName = "poweredOn" stubbedIsBluetoothPoweredOff = false stubbedIsDeviceConnectedToSystem = false + isDeviceConnectedToSystemHandler = nil stubbedDidStartAdoptingSystemConnectedPeripheral = false activateCallCount = 0 isDeviceConnectedToSystemCalls = [] @@ -174,6 +183,10 @@ extension MockBLEStateMachine { stubbedIsDeviceConnectedToSystem = value } + func setIsDeviceConnectedToSystemHandler(_ handler: sending (@Sendable (UUID) -> Bool)?) { + isDeviceConnectedToSystemHandler = handler + } + func setStubbedIsBluetoothPoweredOff(_ value: Bool) { stubbedIsBluetoothPoweredOff = value } diff --git a/PocketMeshServices/Tests/PocketMeshServicesTests/Mocks/MockPersistenceStore.swift b/PocketMeshServices/Tests/PocketMeshServicesTests/Mocks/MockPersistenceStore.swift index 296fb8ee..3387ace1 100644 --- a/PocketMeshServices/Tests/PocketMeshServicesTests/Mocks/MockPersistenceStore.swift +++ b/PocketMeshServices/Tests/PocketMeshServicesTests/Mocks/MockPersistenceStore.swift @@ -68,6 +68,28 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { return messages.values.first { $0.ackCode == ackCode } } + public func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + if let error = stubbedFetchMessageError { throw error } + var result: [UUID: [MessageDTO]] = [:] + for contactID in contactIDs { + let filtered = messages.values.filter { $0.contactID == contactID } + .sorted { $0.timestamp < $1.timestamp } + result[contactID] = Array(filtered.prefix(limit)) + } + return result + } + + public func fetchLastChannelMessages(channels: [(deviceID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + if let error = stubbedFetchMessageError { throw error } + var result: [UUID: [MessageDTO]] = [:] + for channel in channels { + let filtered = messages.values.filter { $0.deviceID == channel.deviceID && $0.channelIndex == channel.channelIndex } + .sorted { $0.timestamp < $1.timestamp } + result[channel.id] = Array(filtered.prefix(limit)) + } + return result + } + public func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { if let error = stubbedFetchMessageError { throw error @@ -94,21 +116,14 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { timestampWindow: ClosedRange, limit: Int ) async throws -> MessageDTO? { - if let error = stubbedFetchMessageError { - throw error - } - - let candidates = messages.values.filter { - $0.deviceID == deviceID && - $0.channelIndex == channelIndex && - timestampWindow.contains($0.timestamp) - } - .sorted { - if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } - return $0.createdAt > $1.createdAt - } + let candidates = try await fetchChannelMessageCandidates( + deviceID: deviceID, + channelIndex: channelIndex, + timestampWindow: timestampWindow, + limit: limit + ) - for candidate in candidates.prefix(limit) { + for candidate in candidates { if candidate.direction == .outgoing { guard let localNodeName, parsedReaction.targetSender == localNodeName else { continue @@ -121,7 +136,7 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { let hash = ReactionParser.generateMessageHash( text: candidate.text, - timestamp: candidate.timestamp + timestamp: candidate.reactionTimestamp ) guard hash == parsedReaction.messageHash else { continue } @@ -131,18 +146,40 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { return nil } - public func findDMMessageForReaction( + public func fetchChannelMessageCandidates( + deviceID: UUID, + channelIndex: UInt8, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> [MessageDTO] { + if let error = stubbedFetchMessageError { + throw error + } + + return messages.values.filter { + $0.deviceID == deviceID && + $0.channelIndex == channelIndex && + timestampWindow.contains($0.timestamp) + } + .sorted { + if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } + return $0.createdAt > $1.createdAt + } + .prefix(limit) + .map { $0 } + } + + public func fetchDMMessageCandidates( deviceID: UUID, contactID: UUID, - messageHash: String, timestampWindow: ClosedRange, limit: Int - ) async throws -> MessageDTO? { + ) async throws -> [MessageDTO] { if let error = stubbedFetchMessageError { throw error } - let candidates = messages.values.filter { + return messages.values.filter { $0.deviceID == deviceID && $0.contactID == contactID && timestampWindow.contains($0.timestamp) @@ -151,11 +188,31 @@ public actor MockPersistenceStore: PersistenceStoreProtocol { if $0.timestamp != $1.timestamp { return $0.timestamp > $1.timestamp } return $0.createdAt > $1.createdAt } + .prefix(limit) + .map { $0 } + } + + public func findDMMessageForReaction( + deviceID: UUID, + contactID: UUID, + messageHash: String, + timestampWindow: ClosedRange, + limit: Int + ) async throws -> MessageDTO? { + let candidates = try await fetchDMMessageCandidates( + deviceID: deviceID, + contactID: contactID, + timestampWindow: timestampWindow, + limit: limit + ) + + for candidate in candidates { + // Skip messages that are themselves reactions + if ReactionParser.isReactionText(candidate.text, isDM: true) { continue } - for candidate in candidates.prefix(limit) { let hash = ReactionParser.generateMessageHash( text: candidate.text, - timestamp: candidate.timestamp + timestamp: candidate.reactionTimestamp ) if hash == messageHash { return candidate diff --git a/PocketMeshServices/Tests/PocketMeshServicesTests/Models/DeviceDTOClientRepeatTests.swift b/PocketMeshServices/Tests/PocketMeshServicesTests/Models/DeviceDTOClientRepeatTests.swift index 1f73aae0..2a6d4f28 100644 --- a/PocketMeshServices/Tests/PocketMeshServicesTests/Models/DeviceDTOClientRepeatTests.swift +++ b/PocketMeshServices/Tests/PocketMeshServicesTests/Models/DeviceDTOClientRepeatTests.swift @@ -75,6 +75,29 @@ struct DeviceDTOClientRepeatTests { #expect(device.supportsClientRepeat == true) } + // MARK: - advert location policy + + @Test("sharesLocationPublicly is false for policy none") + func sharesLocationPublicly_none() { + let device = makeDevice().copy { $0.advertLocationPolicy = 0 } + #expect(device.sharesLocationPublicly == false) + #expect(device.advertLocationPolicyMode == .none) + } + + @Test("sharesLocationPublicly is true for policy share") + func sharesLocationPublicly_share() { + let device = makeDevice().copy { $0.advertLocationPolicy = 1 } + #expect(device.sharesLocationPublicly == true) + #expect(device.advertLocationPolicyMode == .share) + } + + @Test("sharesLocationPublicly is true for policy prefs") + func sharesLocationPublicly_prefs() { + let device = makeDevice().copy { $0.advertLocationPolicy = 2 } + #expect(device.sharesLocationPublicly == true) + #expect(device.advertLocationPolicyMode == .prefs) + } + // MARK: - copy @Test("copy mutates only specified fields") diff --git a/PocketMeshServices/Tests/PocketMeshServicesTests/Services/ErrorLocalizationTests.swift b/PocketMeshServices/Tests/PocketMeshServicesTests/Services/ErrorLocalizationTests.swift index 59b6b72c..9c0c0c42 100644 --- a/PocketMeshServices/Tests/PocketMeshServicesTests/Services/ErrorLocalizationTests.swift +++ b/PocketMeshServices/Tests/PocketMeshServicesTests/Services/ErrorLocalizationTests.swift @@ -106,6 +106,18 @@ struct ErrorLocalizationTests { #expect(serviceError.localizedDescription == "Not connected to device.") } + @Test("SettingsServiceError.deviceGPSVerificationFailed is human-readable") + func settingsServiceDeviceGPSVerificationFailed() { + let serviceError: SettingsServiceError = .deviceGPSVerificationFailed( + expectedEnabled: false, + actualEnabled: true + ) + #expect( + serviceError.localizedDescription == + "Device GPS setting was not saved. Expected 'Off' but device reports 'On'." + ) + } + @Test("RemoteNodeError.sessionError passes through without prefix") func remoteNodeSessionPassThrough() { let meshError: MeshCoreError = .bluetoothPoweredOff diff --git a/PocketMeshServices/Tests/PocketMeshServicesTests/Services/LoginTimeoutConfigTests.swift b/PocketMeshServices/Tests/PocketMeshServicesTests/Services/LoginTimeoutConfigTests.swift index f3af9b5a..f537e431 100644 --- a/PocketMeshServices/Tests/PocketMeshServicesTests/Services/LoginTimeoutConfigTests.swift +++ b/PocketMeshServices/Tests/PocketMeshServicesTests/Services/LoginTimeoutConfigTests.swift @@ -6,6 +6,10 @@ import Testing @Suite("LoginTimeoutConfig Tests") struct LoginTimeoutConfigTests { + private func makeSentInfo(timeoutMs: UInt32) -> MessageSentInfo { + MessageSentInfo(type: 0, expectedAck: Data([0x00]), suggestedTimeoutMs: timeoutMs) + } + @Test("Direct path (mode 0) uses base timeout only") func directPathMode0() { // Mode 0, 0 hops → encoded as 0x00 @@ -53,4 +57,40 @@ struct LoginTimeoutConfigTests { let timeout = LoginTimeoutConfig.timeout(forPathLength: 6) #expect(timeout == .seconds(60)) } + + @Test("Login timeout policy clamps long firmware suggestions") + func loginTimeoutPolicyClampsFirmwareSuggestion() { + let sentInfo = makeSentInfo(timeoutMs: 20_000) + + let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0) + + #expect(timeout == .seconds(20)) + } + + @Test("Login timeout policy respects path floor when firmware is shorter") + func loginTimeoutPolicyUsesPathFloor() { + let sentInfo = makeSentInfo(timeoutMs: 1_000) + + let timeout = RemoteOperationTimeoutPolicy.loginTimeout(for: sentInfo, pathLength: 0x43) + + #expect(timeout == .seconds(20)) + } + + @Test("CLI timeout policy clamps long firmware suggestions") + func cliTimeoutPolicyClampsFirmwareSuggestion() { + let sentInfo = makeSentInfo(timeoutMs: 20_000) + + let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) + + #expect(timeout == .seconds(15)) + } + + @Test("CLI timeout policy keeps caller budget when firmware is shorter") + func cliTimeoutPolicyUsesCallerBudgetFloor() { + let sentInfo = makeSentInfo(timeoutMs: 1_000) + + let timeout = RemoteOperationTimeoutPolicy.cliTimeout(for: sentInfo, requestedTimeout: .seconds(10)) + + #expect(timeout == .seconds(10)) + } } diff --git a/PocketMeshServices/Tests/PocketMeshServicesTests/Services/MeshCoreOpenReactionParserTests.swift b/PocketMeshServices/Tests/PocketMeshServicesTests/Services/MeshCoreOpenReactionParserTests.swift new file mode 100644 index 00000000..8a2ac629 --- /dev/null +++ b/PocketMeshServices/Tests/PocketMeshServicesTests/Services/MeshCoreOpenReactionParserTests.swift @@ -0,0 +1,494 @@ +import Foundation +import Testing +@testable import PocketMeshServices + +@Suite("MeshCoreOpenReactionParser Tests") +struct MeshCoreOpenReactionParserTests { + + // MARK: - Parse Valid Format Tests + + @Test("Parses valid reaction with thumbs up (index 00)") + func parsesThumbsUp() { + let result = MeshCoreOpenReactionParser.parse("r:a1b2:00") + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.dartHash == "a1b2") + } + + @Test("Parses valid reaction with fire (index 05)") + func parsesFire() { + let result = MeshCoreOpenReactionParser.parse("r:ff00:05") + + #expect(result != nil) + #expect(result?.emoji == "🔥") + #expect(result?.dartHash == "ff00") + } + + @Test("Parses valid reaction with heart (index 01)") + func parsesHeart() { + let result = MeshCoreOpenReactionParser.parse("r:1234:01") + + #expect(result != nil) + #expect(result?.emoji == "❤️") + #expect(result?.dartHash == "1234") + } + + @Test("Parses reaction at max valid emoji index (0xb7)") + func parsesMaxIndex() { + let result = MeshCoreOpenReactionParser.parse("r:abcd:b7") + + #expect(result != nil) + #expect(result?.emoji == "🚀") + #expect(result?.dartHash == "abcd") + } + + // MARK: - Parse Invalid Format Tests + + @Test("Rejects plain text") + func rejectsPlainText() { + #expect(MeshCoreOpenReactionParser.parse("hello world") == nil) + } + + @Test("Rejects wrong prefix") + func rejectsWrongPrefix() { + #expect(MeshCoreOpenReactionParser.parse("x:a1b2:00") == nil) + } + + @Test("Rejects uppercase hex in hash") + func rejectsUppercaseHash() { + #expect(MeshCoreOpenReactionParser.parse("r:A1B2:00") == nil) + } + + @Test("Rejects uppercase hex in index") + func rejectsUppercaseIndex() { + #expect(MeshCoreOpenReactionParser.parse("r:a1b2:0A") == nil) + } + + @Test("Rejects too short") + func rejectsTooShort() { + #expect(MeshCoreOpenReactionParser.parse("r:a1b:00") == nil) + } + + @Test("Rejects too long") + func rejectsTooLong() { + #expect(MeshCoreOpenReactionParser.parse("r:a1b2c:00") == nil) + } + + @Test("Rejects missing colons") + func rejectsMissingColons() { + #expect(MeshCoreOpenReactionParser.parse("r-a1b2-00") == nil) + } + + @Test("Rejects emoji index beyond table size") + func rejectsOutOfRangeIndex() { + // 0xb8 = 184, table has 184 entries (0x00–0xb7) + #expect(MeshCoreOpenReactionParser.parse("r:a1b2:b8") == nil) + } + + @Test("Rejects PocketMesh channel reaction format") + func rejectsPocketMeshChannelFormat() { + #expect(MeshCoreOpenReactionParser.parse("👍@[AlphaNode]\n7f3a9c12") == nil) + } + + @Test("Rejects PocketMesh DM reaction format") + func rejectsPocketMeshDMFormat() { + #expect(MeshCoreOpenReactionParser.parse("👍\n7f3a9c12") == nil) + } + + @Test("Rejects empty string") + func rejectsEmpty() { + #expect(MeshCoreOpenReactionParser.parse("") == nil) + } + + // MARK: - Emoji Index Mapping Tests + + @Test("Spot-check emoji indices across all categories") + func spotCheckEmojiIndices() { + // quickEmojis + #expect(MeshCoreOpenReactionParser.parse("r:0000:00")?.emoji == "👍") // 0x00 + #expect(MeshCoreOpenReactionParser.parse("r:0000:02")?.emoji == "😂") // 0x02 + #expect(MeshCoreOpenReactionParser.parse("r:0000:03")?.emoji == "🎉") // 0x03 + + // smileys start at 0x06 + #expect(MeshCoreOpenReactionParser.parse("r:0000:06")?.emoji == "😀") // first smiley + #expect(MeshCoreOpenReactionParser.parse("r:0000:45")?.emoji == "😶") // last smiley + + // gestures start at 0x46 + #expect(MeshCoreOpenReactionParser.parse("r:0000:46")?.emoji == "👍") // first gesture + #expect(MeshCoreOpenReactionParser.parse("r:0000:66")?.emoji == "💪") // last gesture + + // hearts start at 0x67 + #expect(MeshCoreOpenReactionParser.parse("r:0000:67")?.emoji == "❤️") // first heart + + // objects start at 0x87 + #expect(MeshCoreOpenReactionParser.parse("r:0000:87")?.emoji == "🎉") // first object + } + + // MARK: - Dart String Hash Tests + + @Test("Dart hash of empty input produces 1") + func dartHashEmpty() { + // Dart: "".hashCode should be 0, which becomes 1 (zero-guard) + let hash = MeshCoreOpenReactionParser.dartStringHash([]) + #expect(hash == 1) + } + + @Test("Dart hash is deterministic") + func dartHashDeterministic() { + let units: [UInt16] = Array("hello".utf16) + let hash1 = MeshCoreOpenReactionParser.dartStringHash(units) + let hash2 = MeshCoreOpenReactionParser.dartStringHash(units) + #expect(hash1 == hash2) + } + + @Test("Dart hash of single character 'a'") + func dartHashSingleChar() { + // Manually compute: code_unit = 97 (0x61) + // hash = 0 + // hash += 97 → 97 + // hash += 97 << 10 → 97 + 99328 = 99425 + // hash ^= 99425 >> 6 → 99425 ^ 1553 = 100464 + // finalize: + // hash += 100464 << 3 → 100464 + 803712 = 904176 + // hash ^= 904176 >> 11 → 904176 ^ 441 = 904617 + // hash += 904617 << 15 → 904617 + 29640630272 (wraps in UInt32) → need wrapping + // Let's just verify it's > 0 and within 30 bits + let hash = MeshCoreOpenReactionParser.dartStringHash([97]) + #expect(hash > 0) + #expect(hash < (1 << 30)) + } + + @Test("Dart hash result is within 30-bit range") + func dartHashRange() { + let units: [UInt16] = Array("test string with various chars 🎉".utf16) + let hash = MeshCoreOpenReactionParser.dartStringHash(units) + #expect(hash > 0) + #expect(hash <= (1 << 30) - 1) + } + + @Test("Different inputs produce different hashes") + func dartHashDifferentInputs() { + let hash1 = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) + let hash2 = MeshCoreOpenReactionParser.dartStringHash(Array("world".utf16)) + #expect(hash1 != hash2) + } + + // MARK: - Hash Computation Tests + + @Test("computeReactionHash returns 4-char lowercase hex") + func hashFormat() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "AlphaNode", + text: "Hello world" + ) + #expect(hash.count == 4) + #expect(hash.allSatisfy { $0.isHexDigit && !$0.isUppercase }) + } + + @Test("computeReactionHash is deterministic") + func hashDeterministic() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "AlphaNode", + text: "Hello world" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "AlphaNode", + text: "Hello world" + ) + #expect(hash1 == hash2) + } + + @Test("computeReactionHash changes with different timestamp") + func hashChangesWithTimestamp() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "Node", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000001, + senderName: "Node", + text: "Hello" + ) + #expect(hash1 != hash2) + } + + @Test("computeReactionHash changes with different sender") + func hashChangesWithSender() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "AlphaNode", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "BetaNode", + text: "Hello" + ) + #expect(hash1 != hash2) + } + + @Test("computeReactionHash changes with different text") + func hashChangesWithText() { + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "Node", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "Node", + text: "World" + ) + #expect(hash1 != hash2) + } + + @Test("computeReactionHash with nil sender (DM mode)") + func hashDMMode() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: nil, + text: "Hello world" + ) + #expect(hash.count == 4) + + // Should differ from channel mode with same params + let channelHash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "Node", + text: "Hello world" + ) + #expect(hash != channelHash) + } + + @Test("computeReactionHash truncates text to 5 UTF-16 code units") + func hashTruncatesText() { + // "Hello" is 5 code units, "Hello world" has 11 + // Both should produce the same hash since only first 5 code units are used + let hash1 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "Node", + text: "Hello" + ) + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "Node", + text: "Hello world" + ) + #expect(hash1 == hash2) + } + + @Test("computeReactionHash handles short text (fewer than 5 code units)") + func hashShortText() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: nil, + text: "Hi" + ) + #expect(hash.count == 4) + } + + // MARK: - UTF-16 Edge Cases + + @Test("computeReactionHash handles emoji in text (multi-code-unit)") + func hashEmojiText() { + // 🎉 is 2 UTF-16 code units (surrogate pair), so "🎉abc" = 5 code units + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: nil, + text: "🎉abc" + ) + #expect(hash.count == 4) + + // "🎉abcdef" should hash the same since first 5 code units match + let hash2 = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: nil, + text: "🎉abcdef" + ) + #expect(hash == hash2) + } + + @Test("computeReactionHash handles empty text") + func hashEmptyText() { + let hash = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "Node", + text: "" + ) + #expect(hash.count == 4) + } + + // MARK: - Cross-App Test Vectors + + @Test("Dart hash matches known Dart VM output for 'hello'") + func dartHashKnownVector() { + // In Dart: "hello".hashCode == 150804507 + // This is the definitive cross-app test vector + let hash = MeshCoreOpenReactionParser.dartStringHash(Array("hello".utf16)) + #expect(hash == 150804507) + } + + @Test("computeReactionHash is internally consistent") + func hashInternalConsistency() { + // Verify computeReactionHash assembles code units correctly + // by comparing against manual dartStringHash call + let testUnits = Array("1700000000AHello".utf16) + let fullHash = MeshCoreOpenReactionParser.dartStringHash(testUnits) + let masked = fullHash & 0xFFFF + let expected = String(format: "%04x", masked) + + let computed = MeshCoreOpenReactionParser.computeReactionHash( + timestamp: 1700000000, + senderName: "A", + text: "Hello" + ) + #expect(computed == expected) + } + + @Test("Emoji table has exactly 184 entries") + func emojiTableSize() { + #expect(MeshCoreOpenReactionParser.emojiTable.count == 184) + } + + // MARK: - V1 Parse Tests + + @Test("Parses v1 reaction from real wire capture") + func parsesV1RealCapture() { + let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_951919033_868488711:👍") + + #expect(result != nil) + #expect(result?.emoji == "👍") + #expect(result?.timestampSeconds == 1_772_600_903) + #expect(result?.senderNameHash == 951_919_033) + #expect(result?.textHash == 868_488_711) + } + + @Test("Parses v1 reaction with heart emoji") + func parsesV1Heart() { + let result = MeshCoreOpenReactionParser.parseV1("r:1700000000000_12345_67890:❤️") + + #expect(result != nil) + #expect(result?.emoji == "❤️") + #expect(result?.timestampSeconds == 1_700_000_000) + #expect(result?.senderNameHash == 12345) + #expect(result?.textHash == 67890) + } + + @Test("Parses v1 reaction with fire emoji") + func parsesV1Fire() { + let result = MeshCoreOpenReactionParser.parseV1("r:1772600903000_100_200:🔥") + + #expect(result != nil) + #expect(result?.emoji == "🔥") + } + + @Test("V1 rejects v3 format") + func v1RejectsV3() { + #expect(MeshCoreOpenReactionParser.parseV1("r:a1b2:00") == nil) + } + + @Test("V1 rejects plain text") + func v1RejectsPlainText() { + #expect(MeshCoreOpenReactionParser.parseV1("hello world") == nil) + } + + @Test("V1 rejects wrong prefix") + func v1RejectsWrongPrefix() { + #expect(MeshCoreOpenReactionParser.parseV1("x:1700000000000_100_200:👍") == nil) + } + + @Test("V1 rejects too few underscore parts") + func v1RejectsTwoParts() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100:👍") == nil) + } + + @Test("V1 rejects too many underscore parts") + func v1RejectsFourParts() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200_300:👍") == nil) + } + + @Test("V1 rejects non-numeric timestamp") + func v1RejectsNonNumericTimestamp() { + #expect(MeshCoreOpenReactionParser.parseV1("r:abc_100_200:👍") == nil) + } + + @Test("V1 rejects non-numeric hash values") + func v1RejectsNonNumericHash() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_abc_200:👍") == nil) + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_xyz:👍") == nil) + } + + @Test("V1 rejects empty emoji") + func v1RejectsEmptyEmoji() { + #expect(MeshCoreOpenReactionParser.parseV1("r:1700000000000_100_200:") == nil) + } + + @Test("V1 rejects PocketMesh channel format") + func v1RejectsPocketMeshChannel() { + #expect(MeshCoreOpenReactionParser.parseV1("👍@[AlphaNode]\n7f3a9c12") == nil) + } + + @Test("V1 rejects empty string") + func v1RejectsEmpty() { + #expect(MeshCoreOpenReactionParser.parseV1("") == nil) + } + + @Test("V1 timestamp converts millis to seconds correctly") + func v1TimestampConversion() { + // 1700000000500 ms → 1700000000 s (truncated, not rounded) + let result = MeshCoreOpenReactionParser.parseV1("r:1700000000500_100_200:👍") + #expect(result?.timestampSeconds == 1_700_000_000) + } + + // MARK: - V1 Hash Matching Tests + + @Test("dartStringHash can verify v1 sender name hash") + func v1SenderNameHashVerification() { + // Compute the Dart hash of a known sender name + let senderName = "TestNode" + let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) + + // Construct a v1 reaction with that hash + let reactionText = "r:1700000000000_\(expectedHash)_12345:👍" + let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) + + #expect(parsed != nil) + #expect(parsed?.senderNameHash == expectedHash) + } + + @Test("dartStringHash can verify v1 text hash") + func v1TextHashVerification() { + let messageText = "Hello from mesh" + let expectedHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) + + let reactionText = "r:1700000000000_12345_\(expectedHash):👍" + let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) + + #expect(parsed != nil) + #expect(parsed?.textHash == expectedHash) + } + + @Test("V1 round-trip: construct reaction and verify both hashes match") + func v1RoundTrip() { + let senderName = "AVN1" + let messageText = "Test message content" + let timestampMs: UInt64 = 1_772_600_903_000 + + let senderHash = MeshCoreOpenReactionParser.dartStringHash(Array(senderName.utf16)) + let textHash = MeshCoreOpenReactionParser.dartStringHash(Array(messageText.utf16)) + + let reactionText = "r:\(timestampMs)_\(senderHash)_\(textHash):👍" + let parsed = MeshCoreOpenReactionParser.parseV1(reactionText) + + #expect(parsed != nil) + #expect(parsed?.timestampSeconds == UInt32(timestampMs / 1000)) + #expect(parsed?.senderNameHash == senderHash) + #expect(parsed?.textHash == textHash) + #expect(parsed?.emoji == "👍") + } +} diff --git a/PocketMeshServices/Tests/PocketMeshServicesTests/Services/SettingsServiceLocationTests.swift b/PocketMeshServices/Tests/PocketMeshServicesTests/Services/SettingsServiceLocationTests.swift new file mode 100644 index 00000000..af51ad18 --- /dev/null +++ b/PocketMeshServices/Tests/PocketMeshServicesTests/Services/SettingsServiceLocationTests.swift @@ -0,0 +1,221 @@ +import Foundation +import Testing +@testable import MeshCore +@testable import PocketMeshServices + +@Suite("SettingsService location and device GPS") +struct SettingsServiceLocationTests { + + @Test("getDeviceGPSState returns unsupported when gps custom var is missing") + @MainActor + func getDeviceGPSState_unsupported() async throws { + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } + + let stateTask = Task { try await service.getDeviceGPSState() } + try await waitUntil("service should request custom vars") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(makeCustomVarsPacket()) + let state = try await stateTask.value + + #expect(state == DeviceGPSState(isSupported: false, isEnabled: false)) + } + + @Test("getDeviceGPSState returns enabled when gps custom var is on") + @MainActor + func getDeviceGPSState_enabled() async throws { + let (service, session, transport) = try await makeService() + defer { Task { await session.stop() } } + + let stateTask = Task { try await service.getDeviceGPSState() } + try await waitUntil("service should request custom vars") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(makeCustomVarsPacket("gps:1,foo:bar")) + let state = try await stateTask.value + + #expect(state == DeviceGPSState(isSupported: true, isEnabled: true)) + } + + @Test("setDeviceGPSEnabledVerified writes, verifies, and refreshes device info") + @MainActor + func setDeviceGPSEnabledVerified_success() async throws { + let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) + defer { Task { await session.stop() } } + + let stateTask = Task { try await service.setDeviceGPSEnabledVerified(false) } + + try await waitUntil("service should send device GPS update") { + await transport.sentData.count == 2 + } + let sentAfterWrite = await transport.sentData + #expect(sentAfterWrite[1] == PacketBuilder.setCustomVar(key: "gps", value: "0")) + await transport.simulateOK() + + try await waitUntil("service should verify device GPS state") { + await transport.sentData.count == 3 + } + let sentAfterVerify = await transport.sentData + #expect(sentAfterVerify[2] == PacketBuilder.getCustomVars()) + await transport.simulateReceive(makeCustomVarsPacket("gps:0")) + + try await waitUntil("service should refresh self info") { + await transport.sentData.count == 4 + } + let sentAfterRefresh = await transport.sentData + #expect(sentAfterRefresh[3] == PacketBuilder.appStart(clientId: SessionConfiguration.default.clientIdentifier)) + await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) + + let state = try await stateTask.value + #expect(state == DeviceGPSState(isSupported: true, isEnabled: false)) + } + + @Test("setManualLocationVerified disables device GPS before writing location") + @MainActor + func setManualLocationVerified_turnsOffGPSFirst() async throws { + let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) + defer { Task { await session.stop() } } + + let saveTask = Task { + try await service.setManualLocationVerified(latitude: 0, longitude: 0) + } + + try await waitUntil("manual save should query device GPS state") { + await transport.sentData.count == 2 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:1")) + + try await waitUntil("manual save should disable device GPS") { + await transport.sentData.count == 3 + } + let sentAfterDisable = await transport.sentData + #expect(sentAfterDisable[2] == PacketBuilder.setCustomVar(key: "gps", value: "0")) + await transport.simulateOK() + + try await waitUntil("manual save should verify device GPS off") { + await transport.sentData.count == 4 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:0")) + + try await waitUntil("manual save should refresh after device GPS change") { + await transport.sentData.count == 5 + } + await transport.simulateReceive(makeSelfInfoPacket(latitude: 47.491031, longitude: -120.339279)) + + try await waitUntil("manual save should send location update") { + await transport.sentData.count == 6 + } + let sentAfterLocationWrite = await transport.sentData + #expect(sentAfterLocationWrite[5] == PacketBuilder.setCoordinates(latitude: 0, longitude: 0)) + await transport.simulateOK() + + try await waitUntil("manual save should verify location through self info") { + await transport.sentData.count == 7 + } + await transport.simulateReceive(makeSelfInfoPacket(latitude: 0, longitude: 0)) + + let selfInfo = try await saveTask.value + #expect(selfInfo.latitude == 0) + #expect(selfInfo.longitude == 0) + } + + @Test("setManualLocationVerified aborts when device GPS stays on") + @MainActor + func setManualLocationVerified_abortsWhenGPSDisableDoesNotStick() async throws { + let (service, session, transport) = try await makeService(initialLatitude: 47.491031, initialLongitude: -120.339279) + defer { Task { await session.stop() } } + + let saveTask = Task { + try await service.setManualLocationVerified(latitude: 0, longitude: 0) + } + + try await waitUntil("manual save should query device GPS state") { + await transport.sentData.count == 2 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:1")) + + try await waitUntil("manual save should disable device GPS") { + await transport.sentData.count == 3 + } + await transport.simulateOK() + + try await waitUntil("manual save should verify device GPS off") { + await transport.sentData.count == 4 + } + await transport.simulateReceive(makeCustomVarsPacket("gps:1")) + + await #expect(throws: SettingsServiceError.self) { + _ = try await saveTask.value + } + + let sent = await transport.sentData + #expect(sent.count == 4) + } + + private func makeService( + initialLatitude: Double = 0, + initialLongitude: Double = 0 + ) async throws -> (SettingsService, MeshCoreSession, MockTransport) { + let transport = MockTransport() + let session = MeshCoreSession(transport: transport) + + let startTask = Task { + try await session.start() + } + + try await waitUntil("session should send app start") { + await transport.sentData.count == 1 + } + + await transport.simulateReceive( + makeSelfInfoPacket(latitude: initialLatitude, longitude: initialLongitude) + ) + try await startTask.value + + return (SettingsService(session: session), session, transport) + } + + private func makeCustomVarsPacket(_ raw: String = "") -> Data { + var packet = Data([ResponseCode.customVars.rawValue]) + packet.append(contentsOf: raw.utf8) + return packet + } + + private func makeSelfInfoPacket( + latitude: Double, + longitude: Double, + advertisementLocationPolicy: UInt8 = 0 + ) -> Data { + var payload = Data() + payload.append(1) + payload.append(22) + payload.append(22) + payload.append(Data(repeating: 0x01, count: 32)) + payload.append(int32Bytes(latitude * 1_000_000)) + payload.append(int32Bytes(longitude * 1_000_000)) + payload.append(0) + payload.append(advertisementLocationPolicy) + payload.append(0) + payload.append(0) + payload.append(uint32Bytes(915_000)) + payload.append(uint32Bytes(125_000)) + payload.append(7) + payload.append(5) + payload.append(contentsOf: "Test".utf8) + + var packet = Data([ResponseCode.selfInfo.rawValue]) + packet.append(payload) + return packet + } + + private func int32Bytes(_ value: Double) -> Data { + withUnsafeBytes(of: Int32(value.rounded()).littleEndian) { Data($0) } + } + + private func uint32Bytes(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.littleEndian) { Data($0) } + } +} diff --git a/PocketMeshServices/Tests/PocketMeshServicesTests/SyncCoordinatorTimestampTests.swift b/PocketMeshServices/Tests/PocketMeshServicesTests/SyncCoordinatorTimestampTests.swift index e1efff4f..5f10953a 100644 --- a/PocketMeshServices/Tests/PocketMeshServicesTests/SyncCoordinatorTimestampTests.swift +++ b/PocketMeshServices/Tests/PocketMeshServicesTests/SyncCoordinatorTimestampTests.swift @@ -221,3 +221,265 @@ struct SyncCoordinatorTimestampTests { #expect(corrected == 0) } } + +// MARK: - Same-Sender Reordering Tests + +@Suite("Same-Sender Reordering") +struct SameSenderReorderingTests { + + private func makeDMMessage( + timestamp: UInt32, + createdAt: Date, + direction: MessageDirection = .incoming + ) -> MessageDTO { + MessageDTO( + id: UUID(), + deviceID: UUID(), + contactID: UUID(), + channelIndex: nil, + text: "msg-\(timestamp)", + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: nil, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + private func makeChannelMessage( + timestamp: UInt32, + createdAt: Date, + senderName: String? = nil, + direction: MessageDirection = .incoming + ) -> MessageDTO { + MessageDTO( + id: UUID(), + deviceID: UUID(), + contactID: nil, + channelIndex: 0, + text: "msg-\(timestamp)", + timestamp: timestamp, + createdAt: createdAt, + direction: direction, + status: .delivered, + textType: .plain, + ackCode: nil, + pathLength: 0, + snr: nil, + senderKeyPrefix: nil, + senderNodeName: senderName, + isRead: false, + replyToID: nil, + roundTripTime: nil, + heardRepeats: 0, + retryAttempt: 0, + maxRetryAttempts: 0 + ) + } + + @Test("Empty array returns empty") + func emptyArray() { + let result = MessageDTO.reorderSameSenderClusters([]) + #expect(result.isEmpty) + } + + @Test("Single message returns unchanged") + func singleMessage() { + let msg = makeDMMessage(timestamp: 100, createdAt: Date()) + let result = MessageDTO.reorderSameSenderClusters([msg]) + #expect(result.count == 1) + #expect(result[0].id == msg.id) + } + + @Test("DM messages within 5 seconds are reordered by sender timestamp") + func dmReorderWithinWindow() { + let base = Date() + // Messages arrived out of order: msg2 arrived first, then msg1 + let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base) + + // Sorted by createdAt: [msg2(t=200), msg1(t=100)] + let input = [msg2, msg1] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Should reorder by sender timestamp: [msg1(t=100), msg2(t=200)] + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test("Outgoing DM messages within 5 seconds are reordered by sender timestamp") + func outgoingDMReorderWithinWindow() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), direction: .outgoing) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base, direction: .outgoing) + + let input = [msg2, msg1] + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test("DM messages beyond 5 seconds are not reordered") + func dmNoReorderBeyondWindow() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(6)) + + let input = [msg1, msg2] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Beyond window — stays in createdAt order + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test("Channel messages from different senders are not clustered") + func channelDifferentSendersNotClustered() { + let base = Date() + // Alice sends at t=200, Bob sends at t=100, both arrive within 2 seconds + let alice = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") + let bob = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2), senderName: "Bob") + + let input = [alice, bob] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Different senders — no reordering + #expect(result[0].senderNodeName == "Alice") + #expect(result[1].senderNodeName == "Bob") + } + + @Test("Channel messages from same sender within window are reordered") + func channelSameSenderReordered() { + let base = Date() + let msg1 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(3), senderName: "Alice") + let msg2 = makeChannelMessage(timestamp: 200, createdAt: base, senderName: "Alice") + + // createdAt order: [msg2(t=200), msg1(t=100)] + let input = [msg2, msg1] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Same sender within window — reordered by timestamp + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test("Mixed directions break clusters") + func mixedDirectionsBreakCluster() { + let base = Date() + let incoming = makeDMMessage(timestamp: 200, createdAt: base, direction: .incoming) + let outgoing = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(1), direction: .outgoing) + + let input = [incoming, outgoing] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Different directions — no reordering + #expect(result[0].direction == .incoming) + #expect(result[1].direction == .outgoing) + } + + @Test("Three messages in cluster are fully sorted") + func threeMessageCluster() { + let base = Date() + // Arrived in reverse order within 4 seconds + let msg1 = makeDMMessage(timestamp: 300, createdAt: base) + let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) + let msg3 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(4)) + + let input = [msg1, msg2, msg3] + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + #expect(result[2].timestamp == 300) + } + + @Test("Exactly 5 second gap is included in cluster") + func exactlyFiveSecondGap() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 200, createdAt: base) + let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(5)) + + let input = [msg1, msg2] + let result = MessageDTO.reorderSameSenderClusters(input) + + // 5 seconds is within window (<=5) + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + } + + @Test("Multiple consecutive clusters are each reordered independently") + func multipleConsecutiveClusters() { + let base = Date() + // Cluster 1: two messages within 3s, out of timestamp order + let c1a = makeDMMessage(timestamp: 200, createdAt: base) + let c1b = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(3)) + + // Gap of 10 seconds separates the clusters + // Cluster 2: two messages within 2s, out of timestamp order + let c2a = makeDMMessage(timestamp: 400, createdAt: base.addingTimeInterval(13)) + let c2b = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(15)) + + let input = [c1a, c1b, c2a, c2b] + let result = MessageDTO.reorderSameSenderClusters(input) + + // Cluster 1 reordered by timestamp + #expect(result[0].timestamp == 100) + #expect(result[1].timestamp == 200) + // Cluster 2 reordered by timestamp + #expect(result[2].timestamp == 300) + #expect(result[3].timestamp == 400) + } + + @Test("Channel messages with nil sender names are not clustered") + func nilSenderNamesNotClustered() { + let base = Date() + let msg1 = makeChannelMessage(timestamp: 200, createdAt: base) + let msg2 = makeChannelMessage(timestamp: 100, createdAt: base.addingTimeInterval(2)) + + let result = MessageDTO.reorderSameSenderClusters([msg1, msg2]) + + // Nil senders should NOT cluster — stays in createdAt order + #expect(result[0].timestamp == 200) + #expect(result[1].timestamp == 100) + } + + @Test("Same-sender messages with identical timestamps use createdAt as tiebreaker") + func identicalTimestampsUsesCreatedAtTiebreaker() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base) + let msg2 = makeDMMessage(timestamp: 100, createdAt: base.addingTimeInterval(0.5)) + + let input = [msg2, msg1] // reverse createdAt order + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].id == msg1.id) + #expect(result[1].id == msg2.id) + } + + @Test("Messages already in correct order are unchanged") + func alreadyCorrectOrder() { + let base = Date() + let msg1 = makeDMMessage(timestamp: 100, createdAt: base) + let msg2 = makeDMMessage(timestamp: 200, createdAt: base.addingTimeInterval(1)) + let msg3 = makeDMMessage(timestamp: 300, createdAt: base.addingTimeInterval(2)) + + let input = [msg1, msg2, msg3] + let result = MessageDTO.reorderSameSenderClusters(input) + + #expect(result[0].id == msg1.id) + #expect(result[1].id == msg2.id) + #expect(result[2].id == msg3.id) + } +} diff --git a/PocketMeshTests/Services/LinkPreviewCacheTests.swift b/PocketMeshTests/Services/LinkPreviewCacheTests.swift index d81d84e1..55dfa815 100644 --- a/PocketMeshTests/Services/LinkPreviewCacheTests.swift +++ b/PocketMeshTests/Services/LinkPreviewCacheTests.swift @@ -251,6 +251,8 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { func fetchMessage(ackCode: UInt32) async throws -> MessageDTO? { nil } func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } func fetchMessages(deviceID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } + func fetchLastChannelMessages(channels: [(deviceID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} func updateMessageByAckCode(_ ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} @@ -358,6 +360,8 @@ private actor MockPreviewDataStore: PersistenceStoreProtocol { func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} func deleteReactionsForMessage(messageID: UUID) async throws {} func findChannelMessageForReaction(deviceID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } + func fetchChannelMessageCandidates(deviceID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } + func fetchDMMessageCandidates(deviceID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } func findDMMessageForReaction(deviceID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } // Notification Level diff --git a/PocketMeshTests/ViewModels/ChatViewModelPaginationTests.swift b/PocketMeshTests/ViewModels/ChatViewModelPaginationTests.swift index 53ad726b..bf1a46ec 100644 --- a/PocketMeshTests/ViewModels/ChatViewModelPaginationTests.swift +++ b/PocketMeshTests/ViewModels/ChatViewModelPaginationTests.swift @@ -57,6 +57,8 @@ private func createTestMessage( contactID: UUID, deviceID: UUID, timestamp: UInt32, + createdAt: Date = Date(), + direction: MessageDirection = .incoming, text: String = "Test message" ) -> MessageDTO { MessageDTO( @@ -66,8 +68,8 @@ private func createTestMessage( channelIndex: nil, text: text, timestamp: timestamp, - createdAt: Date(), - direction: .incoming, + createdAt: createdAt, + direction: direction, status: .delivered, textType: .plain, ackCode: nil, @@ -144,6 +146,26 @@ actor PaginationTestDataStore: PersistenceStoreProtocol { messages.values.first { $0.ackCode == ackCode } } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + for contactID in contactIDs { + let filtered = messages.values.filter { $0.contactID == contactID } + .sorted { $0.timestamp < $1.timestamp } + result[contactID] = Array(filtered.prefix(limit)) + } + return result + } + + func fetchLastChannelMessages(channels: [(deviceID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { + var result: [UUID: [MessageDTO]] = [:] + for channel in channels { + let filtered = messages.values.filter { $0.deviceID == channel.deviceID && $0.channelIndex == channel.channelIndex } + .sorted { $0.timestamp < $1.timestamp } + result[channel.id] = Array(filtered.prefix(limit)) + } + return result + } + func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { if let error = stubbedFetchError { throw error @@ -335,6 +357,8 @@ actor PaginationTestDataStore: PersistenceStoreProtocol { func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} func deleteReactionsForMessage(messageID: UUID) async throws {} func findChannelMessageForReaction(deviceID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } + func fetchChannelMessageCandidates(deviceID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } + func fetchDMMessageCandidates(deviceID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } func findDMMessageForReaction(deviceID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } // MARK: - Notification Level @@ -709,3 +733,109 @@ struct ChatViewModelDisplayItemsPaginationTests { #expect(foundMessage?.id == message1.id) } } + +// MARK: - Cross-Boundary Reordering Tests + +@Suite("Same-Sender Cluster Reordering Across Page Boundaries") +@MainActor +struct CrossBoundaryReorderingTests { + + @Test("Reordering fixes same-sender cluster split across pagination boundary") + func reorderingFixesSplitCluster() { + // Scenario: Sender sends msg1 (t=100), msg2 (t=101), msg3 (t=102) rapidly. + // Mesh delivers them out of order: msg3, msg1, msg2. + // msg3 ends up on page 2 (older), msg1 and msg2 on page 1 (newer). + // + // Each page is reordered independently, but the cross-boundary cluster + // (msg3 on page 2, msg1+msg2 on page 1) is NOT reordered until merge. + + let deviceID = UUID() + let contactID = UUID() + let base = Date(timeIntervalSince1970: 1_000_000) + + // Page 2 (older, loaded second via loadOlderMessages): msg3 arrived first + let msg3 = createTestMessage( + contactID: contactID, + deviceID: deviceID, + timestamp: 102, + createdAt: base.addingTimeInterval(0), // received first + text: "msg3" + ) + + // Page 1 (newer, loaded first): msg1 and msg2 arrived later + let msg1 = createTestMessage( + contactID: contactID, + deviceID: deviceID, + timestamp: 100, + createdAt: base.addingTimeInterval(2), // received second + text: "msg1" + ) + let msg2 = createTestMessage( + contactID: contactID, + deviceID: deviceID, + timestamp: 101, + createdAt: base.addingTimeInterval(3), // received third + text: "msg2" + ) + + // Simulate independent per-page reordering (as production does) + let page2Reordered = MessageDTO.reorderSameSenderClusters([msg3]) // single msg, no-op + let page1Reordered = MessageDTO.reorderSameSenderClusters([msg1, msg2]) // already ordered + + // Merge: prepend older page + var merged = page2Reordered + merged.append(contentsOf: page1Reordered) + + // Without cross-boundary reordering: msg3, msg1, msg2 (receive order at boundary) + #expect(merged.map(\.text) == ["msg3", "msg1", "msg2"]) + + // After re-running reorderSameSenderClusters on the full merged array + let fixed = MessageDTO.reorderSameSenderClusters(merged) + + // All three are from the same sender (DM, same direction), within 5s window, + // so they're reordered by sender timestamp: msg1, msg2, msg3 + #expect(fixed.map(\.text) == ["msg1", "msg2", "msg3"]) + } + + @Test("Reordering does not merge clusters beyond the 5-second window") + func reorderingRespectsWindowAtBoundary() { + let deviceID = UUID() + let contactID = UUID() + let base = Date(timeIntervalSince1970: 1_000_000) + + // Page 2 message: received well before the page 1 messages (>5s gap) + let oldMsg = createTestMessage( + contactID: contactID, + deviceID: deviceID, + timestamp: 100, + createdAt: base.addingTimeInterval(0), + text: "old" + ) + + // Page 1 messages: received 10 seconds later + let newMsg1 = createTestMessage( + contactID: contactID, + deviceID: deviceID, + timestamp: 99, // earlier sender timestamp but later receive + createdAt: base.addingTimeInterval(10), + text: "new1" + ) + let newMsg2 = createTestMessage( + contactID: contactID, + deviceID: deviceID, + timestamp: 102, + createdAt: base.addingTimeInterval(11), + text: "new2" + ) + + // Merge: prepend older page + var merged = [oldMsg] + merged.append(contentsOf: [newMsg1, newMsg2]) + + let result = MessageDTO.reorderSameSenderClusters(merged) + + // The 10-second gap between oldMsg and newMsg1 exceeds the 5s window, + // so they should NOT be clustered — order stays as-is + #expect(result.map(\.text) == ["old", "new1", "new2"]) + } +} diff --git a/PocketMeshTests/ViewModels/ChatViewModelTests.swift b/PocketMeshTests/ViewModels/ChatViewModelTests.swift index 9c6b2c8a..f74a06f8 100644 --- a/PocketMeshTests/ViewModels/ChatViewModelTests.swift +++ b/PocketMeshTests/ViewModels/ChatViewModelTests.swift @@ -31,14 +31,17 @@ private func createTestContact( private func createTestMessage( timestamp: UInt32, + createdAt: Date? = nil, text: String = "Test message" ) -> MessageDTO { + let resolvedCreatedAt = createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)) let message = Message( id: UUID(), deviceID: UUID(), contactID: UUID(), text: text, timestamp: timestamp, + createdAt: resolvedCreatedAt, directionRawValue: MessageDirection.outgoing.rawValue, statusRawValue: MessageStatus.sent.rawValue ) @@ -47,6 +50,7 @@ private func createTestMessage( private func createChannelMessage( timestamp: UInt32, + createdAt: Date? = nil, senderName: String? = nil, isOutgoing: Bool = false, text: String = "Test message" @@ -58,7 +62,7 @@ private func createChannelMessage( channelIndex: 0, text: text, timestamp: timestamp, - createdAt: Date(), + createdAt: createdAt ?? Date(timeIntervalSince1970: TimeInterval(timestamp)), direction: isOutgoing ? .outgoing : .incoming, status: isOutgoing ? .sent : .delivered, textType: .plain, @@ -190,6 +194,30 @@ struct ChatViewModelTests { #expect(ChatViewModel.computeDisplayFlags(for: messages[0], previous: nil).showTimestamp == true) } + @Test("Time gap uses createdAt not timestamp when they diverge") + func timeGapUsesCreatedAtNotTimestamp() { + // Sender timestamps are 10 minutes apart, but messages arrived 1 second apart + let base = Date(timeIntervalSince1970: 1000) + let msg1 = createTestMessage(timestamp: 1000, createdAt: base) + let msg2 = createTestMessage(timestamp: 1600, createdAt: base.addingTimeInterval(1)) + + let flags = ChatViewModel.computeDisplayFlags(for: msg2, previous: msg1) + // createdAt gap is 1s (no timestamp shown), even though sender timestamps differ by 600s + #expect(flags.showTimestamp == false) + } + + @Test("Time gap triggers timestamp when createdAt gap is large despite close sender timestamps") + func timeGapTriggersOnCreatedAtGap() { + // Sender timestamps are 1 second apart, but messages arrived 6 minutes apart + let base = Date(timeIntervalSince1970: 1000) + let msg1 = createTestMessage(timestamp: 1000, createdAt: base) + let msg2 = createTestMessage(timestamp: 1001, createdAt: base.addingTimeInterval(361)) + + let flags = ChatViewModel.computeDisplayFlags(for: msg2, previous: msg1) + // createdAt gap is 361s (> 300s), so timestamp should show + #expect(flags.showTimestamp == true) + } + @Test("Large time gaps show timestamp") func largeTimeGapsShowTimestamp() { let baseTime: UInt32 = 1000 diff --git a/PocketMeshTests/ViewModels/LineOfSightViewModelTests.swift b/PocketMeshTests/ViewModels/LineOfSightViewModelTests.swift index 9b40a2e7..3bf8cb74 100644 --- a/PocketMeshTests/ViewModels/LineOfSightViewModelTests.swift +++ b/PocketMeshTests/ViewModels/LineOfSightViewModelTests.swift @@ -82,6 +82,8 @@ actor MockPersistenceStore: PersistenceStoreProtocol { func fetchMessage(ackCode: UInt32) async throws -> MessageDTO? { nil } func fetchMessages(contactID: UUID, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } func fetchMessages(deviceID: UUID, channelIndex: UInt8, limit: Int, offset: Int) async throws -> [MessageDTO] { [] } + func fetchLastMessages(contactIDs: [UUID], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } + func fetchLastChannelMessages(channels: [(deviceID: UUID, channelIndex: UInt8, id: UUID)], limit: Int) throws -> [UUID: [MessageDTO]] { [:] } func deleteMessagesForContact(contactID: UUID) async throws {} func updateMessageStatus(id: UUID, status: MessageStatus) async throws {} func updateMessageAck(id: UUID, ackCode: UInt32, status: MessageStatus, roundTripTime: UInt32?) async throws {} @@ -190,6 +192,8 @@ actor MockPersistenceStore: PersistenceStoreProtocol { func updateMessageReactionSummary(messageID: UUID, summary: String?) async throws {} func deleteReactionsForMessage(messageID: UUID) async throws {} func findChannelMessageForReaction(deviceID: UUID, channelIndex: UInt8, parsedReaction: ParsedReaction, localNodeName: String?, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } + func fetchChannelMessageCandidates(deviceID: UUID, channelIndex: UInt8, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } + func fetchDMMessageCandidates(deviceID: UUID, contactID: UUID, timestampWindow: ClosedRange, limit: Int) async throws -> [MessageDTO] { [] } func findDMMessageForReaction(deviceID: UUID, contactID: UUID, messageHash: String, timestampWindow: ClosedRange, limit: Int) async throws -> MessageDTO? { nil } // MARK: - Notification Level (stubs) diff --git a/PocketMeshTests/Views/Tools/CLI/CLICompletionEngineTests.swift b/PocketMeshTests/Views/Tools/CLI/CLICompletionEngineTests.swift index bd50c555..705b23a9 100644 --- a/PocketMeshTests/Views/Tools/CLI/CLICompletionEngineTests.swift +++ b/PocketMeshTests/Views/Tools/CLI/CLICompletionEngineTests.swift @@ -380,6 +380,75 @@ struct CLICompletionEngineTests { #expect(suggestions.isEmpty) } + // MARK: - v1.14.0 New Commands + + @Test("advert.zerohop appears in remote session suggestions") + func advertZerohopAppearsInRemote() { + let engine = createEngine() + let suggestions = engine.completions(for: "advert", isLocal: false) + + #expect(suggestions.contains("advert")) + #expect(suggestions.contains("advert.zerohop")) + } + + @Test("discover.neighbors appears in remote session suggestions") + func discoverNeighborsAppearsInRemote() { + let engine = createEngine() + let suggestions = engine.completions(for: "disc", isLocal: false) + + #expect(suggestions.contains("discover.neighbors")) + } + + @Test("New get/set params appear in completions") + func newGetSetParamsAppear() { + let engine = createEngine() + let suggestions = engine.completions(for: "get ", isLocal: false) + + #expect(suggestions.contains("path.hash.mode")) + #expect(suggestions.contains("loop.detect")) + #expect(suggestions.contains("bootloader.ver")) + } + + @Test("set loop.detect suggests values") + func setLoopDetectSuggestsValues() { + let engine = createEngine() + let suggestions = engine.completions(for: "set loop.detect ", isLocal: false) + + #expect(suggestions == ["minimal", "moderate", "off", "strict"]) + } + + @Test("set path.hash.mode suggests values") + func setPathHashModeSuggestsValues() { + let engine = createEngine() + let suggestions = engine.completions(for: "set path.hash.mode ", isLocal: false) + + #expect(suggestions == ["0", "1", "2"]) + } + + @Test("set loop.detect returns empty after value complete") + func setLoopDetectReturnsEmptyAfterValue() { + let engine = createEngine() + let suggestions = engine.completions(for: "set loop.detect off ", isLocal: false) + + #expect(suggestions.isEmpty) + } + + @Test("get loop.detect returns empty after param (no value completion for get)") + func getLoopDetectNoValueCompletion() { + let engine = createEngine() + let suggestions = engine.completions(for: "get loop.detect ", isLocal: false) + + #expect(suggestions.isEmpty) + } + + @Test("set loop.detect filters values by prefix") + func setLoopDetectFiltersPrefix() { + let engine = createEngine() + let suggestions = engine.completions(for: "set loop.detect m", isLocal: false) + + #expect(suggestions == ["minimal", "moderate"]) + } + @Test("Uppercase GPS advert still suggests values") func uppercaseGpsAdvertSuggestsValues() { let engine = createEngine() diff --git a/PocketMeshWidgets/MeshStatusLiveActivity.swift b/PocketMeshWidgets/MeshStatusLiveActivity.swift index 010fbe38..edcb12b3 100644 --- a/PocketMeshWidgets/MeshStatusLiveActivity.swift +++ b/PocketMeshWidgets/MeshStatusLiveActivity.swift @@ -60,11 +60,6 @@ struct MeshStatusLiveActivity: Widget { .accessibilityElement(children: .combine) } - if !context.state.isConnected, let date = context.state.disconnectedDate { - Text(date, style: .relative) - .font(.caption) - .foregroundStyle(.secondary) - } } } compactLeading: { Image(systemName: context.state.antennaIconName) diff --git a/PocketMeshWidgets/Resources/de.lproj/Localizable.strings b/PocketMeshWidgets/Resources/de.lproj/Localizable.strings index d8509048..61534bda 100644 --- a/PocketMeshWidgets/Resources/de.lproj/Localizable.strings +++ b/PocketMeshWidgets/Resources/de.lproj/Localizable.strings @@ -1,6 +1,5 @@ /* Widget localization — German */ "Disconnected" = "Getrennt"; -"Disconnected %@ ago" = "Getrennt seit %@"; "%lld packets per minute" = "%lld Pakete pro Minute"; "Battery %lld percent" = "Akku %lld Prozent"; diff --git a/PocketMeshWidgets/Resources/en.lproj/Localizable.strings b/PocketMeshWidgets/Resources/en.lproj/Localizable.strings index 892effee..1879ac62 100644 --- a/PocketMeshWidgets/Resources/en.lproj/Localizable.strings +++ b/PocketMeshWidgets/Resources/en.lproj/Localizable.strings @@ -3,8 +3,6 @@ /* Connection status shown when radio is disconnected */ "Disconnected" = "Disconnected"; -/* Accessibility: disconnected duration on Lock Screen */ -"Disconnected %@ ago" = "Disconnected %@ ago"; /* Accessibility: packet rate */ "%lld packets per minute" = "%lld packets per minute"; diff --git a/PocketMeshWidgets/Resources/es.lproj/Localizable.strings b/PocketMeshWidgets/Resources/es.lproj/Localizable.strings index 7911dcc6..7659a187 100644 --- a/PocketMeshWidgets/Resources/es.lproj/Localizable.strings +++ b/PocketMeshWidgets/Resources/es.lproj/Localizable.strings @@ -1,6 +1,5 @@ /* Widget localization — Spanish */ "Disconnected" = "Desconectado"; -"Disconnected %@ ago" = "Desconectado hace %@"; "%lld packets per minute" = "%lld paquetes por minuto"; "Battery %lld percent" = "Batería %lld por ciento"; diff --git a/PocketMeshWidgets/Resources/fr.lproj/Localizable.strings b/PocketMeshWidgets/Resources/fr.lproj/Localizable.strings index 1984ce35..d6b7e3af 100644 --- a/PocketMeshWidgets/Resources/fr.lproj/Localizable.strings +++ b/PocketMeshWidgets/Resources/fr.lproj/Localizable.strings @@ -1,6 +1,5 @@ /* Widget localization — French */ "Disconnected" = "Déconnecté"; -"Disconnected %@ ago" = "Déconnecté depuis %@"; "%lld packets per minute" = "%lld paquets par minute"; "Battery %lld percent" = "Batterie %lld pour cent"; diff --git a/PocketMeshWidgets/Resources/nl.lproj/Localizable.strings b/PocketMeshWidgets/Resources/nl.lproj/Localizable.strings index 7b3eb53a..1b10f363 100644 --- a/PocketMeshWidgets/Resources/nl.lproj/Localizable.strings +++ b/PocketMeshWidgets/Resources/nl.lproj/Localizable.strings @@ -1,6 +1,5 @@ /* Widget localization — Dutch */ "Disconnected" = "Niet verbonden"; -"Disconnected %@ ago" = "Niet verbonden sinds %@"; "%lld packets per minute" = "%lld pakketten per minuut"; "Battery %lld percent" = "Batterij %lld procent"; diff --git a/PocketMeshWidgets/Resources/pl.lproj/Localizable.strings b/PocketMeshWidgets/Resources/pl.lproj/Localizable.strings index 41a44ee0..096c6667 100644 --- a/PocketMeshWidgets/Resources/pl.lproj/Localizable.strings +++ b/PocketMeshWidgets/Resources/pl.lproj/Localizable.strings @@ -1,6 +1,5 @@ /* Widget localization — Polish */ "Disconnected" = "Rozłączono"; -"Disconnected %@ ago" = "Rozłączono %@ temu"; "%lld packets per minute" = "%lld pakietów na minutę"; "Battery %lld percent" = "Bateria %lld procent"; diff --git a/PocketMeshWidgets/Resources/ru.lproj/Localizable.strings b/PocketMeshWidgets/Resources/ru.lproj/Localizable.strings index ac88cd7e..c296d35a 100644 --- a/PocketMeshWidgets/Resources/ru.lproj/Localizable.strings +++ b/PocketMeshWidgets/Resources/ru.lproj/Localizable.strings @@ -1,6 +1,5 @@ /* Widget localization — Russian */ "Disconnected" = "Отключено"; -"Disconnected %@ ago" = "Отключено %@ назад"; "%lld packets per minute" = "%lld пакетов в минуту"; "Battery %lld percent" = "Батарея %lld процентов"; diff --git a/PocketMeshWidgets/Resources/uk.lproj/Localizable.strings b/PocketMeshWidgets/Resources/uk.lproj/Localizable.strings index 1657b8d3..c8137bcf 100644 --- a/PocketMeshWidgets/Resources/uk.lproj/Localizable.strings +++ b/PocketMeshWidgets/Resources/uk.lproj/Localizable.strings @@ -1,6 +1,5 @@ /* Widget localization — Ukrainian */ "Disconnected" = "Від'єднано"; -"Disconnected %@ ago" = "Від'єднано %@ тому"; "%lld packets per minute" = "%lld пакетів за хвилину"; "Battery %lld percent" = "Батарея %lld відсотків"; diff --git a/PocketMeshWidgets/Resources/zh-Hans.lproj/Localizable.strings b/PocketMeshWidgets/Resources/zh-Hans.lproj/Localizable.strings index 3ca57d0f..325e922e 100644 --- a/PocketMeshWidgets/Resources/zh-Hans.lproj/Localizable.strings +++ b/PocketMeshWidgets/Resources/zh-Hans.lproj/Localizable.strings @@ -1,6 +1,5 @@ /* Widget localization — Simplified Chinese */ "Disconnected" = "已断开"; -"Disconnected %@ ago" = "已断开 %@"; "%lld packets per minute" = "每分钟 %lld 个数据包"; "Battery %lld percent" = "电量 %lld%%"; diff --git a/PocketMeshWidgets/Views/LockScreenView.swift b/PocketMeshWidgets/Views/LockScreenView.swift index 5d0920b1..b31f8080 100644 --- a/PocketMeshWidgets/Views/LockScreenView.swift +++ b/PocketMeshWidgets/Views/LockScreenView.swift @@ -42,15 +42,6 @@ struct LockScreenView: View { .accessibilityLabel("\(context.state.unreadCount) unread messages") } - if !context.state.isConnected, let disconnectedDate = context.state.disconnectedDate { - HStack { - Spacer() - Text(disconnectedDate, style: .relative) - .foregroundStyle(.secondary) - .font(.caption) - } - .accessibilityLabel("Disconnected \(Text(disconnectedDate, style: .relative)) ago") - } } .padding() .accessibilityElement(children: .combine) diff --git a/project.yml b/project.yml index ae471bca..ab58310e 100644 --- a/project.yml +++ b/project.yml @@ -62,7 +62,7 @@ targets: base: SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon - MARKETING_VERSION: 0.10.0 + MARKETING_VERSION: 0.10.1 CURRENT_PROJECT_VERSION: 1 VERSIONING_SYSTEM: apple-generic TARGETED_DEVICE_FAMILY: "1,2" @@ -109,7 +109,7 @@ targets: settings: base: INFOPLIST_FILE: PocketMeshTests/Info.plist - MARKETING_VERSION: 0.10.0 + MARKETING_VERSION: 0.10.1 CURRENT_PROJECT_VERSION: 1 VERSIONING_SYSTEM: apple-generic TEST_HOST: "$(BUILT_PRODUCTS_DIR)/PocketMesh.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/PocketMesh" @@ -123,7 +123,7 @@ targets: - path: Shared settings: base: - MARKETING_VERSION: 0.10.0 + MARKETING_VERSION: 0.10.1 CURRENT_PROJECT_VERSION: 1 VERSIONING_SYSTEM: apple-generic TARGETED_DEVICE_FAMILY: "1,2"