diff --git a/Localizable.xcstrings b/Localizable.xcstrings index b7835609..44aa74bb 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -18921,6 +18921,13 @@ } } } + }, + "Last seen device:" : { + "comment" : "A label displayed next to the last seen device text in the `DeviceConnectRow`.", + "isCommentAutoGenerated" : true + }, + "Last seen device: %@" : { + }, "Latitude" : { "localizations" : { @@ -20441,6 +20448,9 @@ } } } + }, + "Manual Connections" : { + }, "Map Data" : { "localizations" : { @@ -20926,8 +20936,8 @@ } } }, - "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : { - "comment" : "A description of Meshtastic's data collection practices.", + "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : { + "comment" : "Privacy policy text for Meshtastic.", "isCommentAutoGenerated" : true }, "Meshtastic Node %@ has shared channels with you" : { @@ -28470,6 +28480,7 @@ } }, "Received Ack" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -28532,8 +28543,12 @@ } } } + }, + "Received Ack: %@" : { + }, "Recipient Ack" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -28596,6 +28611,9 @@ } } } + }, + "Recipient Ack: %@" : { + }, "Recording route" : { "localizations" : { @@ -28779,6 +28797,16 @@ } } }, + "Relayed by %d %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Relayed by %1$d %2$@" + } + } + } + }, "Release Notes" : { "localizations" : { "it" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 7a4a216f..ee8aba2a 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 2344A2B12D68DFF800170A77 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C49D8F2C471AEA0024FBD1 /* Constants.swift */; }; 2346A7192E2FB9A300CB9239 /* SerialConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A7182E2FB9A300CB9239 /* SerialConnection.swift */; }; 2346A71D2E2FB9C500CB9239 /* SerialTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */; }; + 2349A04A2EAE4DA30060A581 /* ManualConnectionList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */; }; 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; }; 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; }; 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */; }; @@ -357,6 +358,7 @@ 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = ""; }; 2346A7182E2FB9A300CB9239 /* SerialConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConnection.swift; sourceTree = ""; }; 2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTransport.swift; sourceTree = ""; }; + 2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualConnectionList.swift; sourceTree = ""; }; 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = ""; }; @@ -832,6 +834,7 @@ 23D316922E5618D2002FA4FB /* AsyncGate.swift */, 23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */, 23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */, + 2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */, ); path = Helpers; sourceTree = ""; @@ -1841,6 +1844,7 @@ BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */, DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */, 2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */, + 2349A04A2EAE4DA30060A581 /* ManualConnectionList.swift in Sources */, 2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */, D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */, 237AEB8F2E1FE457003B7CE3 /* Transport.swift in Sources */, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index df627fbe..d63f8acf 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -67,7 +67,12 @@ extension AccessoryManager { } self.activeConnection = (device: device, connection: connection) - if UserDefaults.preferredPeripheralId.count < 1 { + // if we don't have a peripheralId, set it now at the beginning of the + // connect process (because I think it is used in other parts of the app + // during the connect process? + // Otherwise, UserDefault.preferredPeripheralId is set in the Connect + // view, as part of the "Connect to new radio?" confirmation dialog logic. + if UserDefaults.preferredPeripheralId.isEmpty { UserDefaults.preferredPeripheralId = device.id.uuidString } } catch let error as CBError where error.code == .peerRemovedPairingInformation { @@ -168,6 +173,15 @@ extension AccessoryManager { // We have an active connection self.updateDevice(deviceId: device.id, key: \.connectionState, value: .connected) self.updateState(.subscribed) + + // If we successfully connected to a manual connection, then save it to the list + // Remember, Device is a value type (struct) so don't use use `device` here, thats + // The value at the instantiation of the connect process. We want the currently + // updated device object in `activeConnection` with its additonal metadata from + // NodeInfo packets. + if let activeDevice = self.activeConnection?.device, activeDevice.isManualConnection { + ManualConnectionList.shared.insert(device: activeDevice) + } } // Step 8: Update UI and status to connected diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift index 539f4a5e..5bcead9b 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift @@ -116,6 +116,15 @@ extension AccessoryManager { updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?") updateDevice(deviceId: activeDevice.id, key: \.longName, value: user.longName ?? "Unknown".localized) updateDevice(deviceId: activeDevice.id, key: \.hardwareModel, value: user.hwModel) + + if activeDevice.isManualConnection { + // We just received a NodeInfo for the currently connected node and this is a + // manual connection. Update the metadata for the device entry in UserDefaults + // with this information for better display later + ManualConnectionList.shared.updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName) + ManualConnectionList.shared.updateDevice(deviceId: activeDevice.id, key: \.longName, value: user.longName) + ManualConnectionList.shared.updateDevice(deviceId: activeDevice.id, key: \.hardwareModel, value: user.hwModel) + } } } } diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 88b70032..f5a114ba 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -302,9 +302,9 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { Logger.transport.error("updateDevice with nil deviceId") return } - - // Update the active device - if let activeConnection { + + // Update the active device if the UUID's match + if let activeConnection, activeConnection.device.id == deviceId { var device = activeConnection.device if device[keyPath: key] != value { // Update the @Published stuff for the UI @@ -714,7 +714,8 @@ extension AccessoryManager { await self.heartbeatTimer?.cancel(withReason: "Duplicate setup, cancelling previous timer") self.heartbeatTimer = nil } - self.heartbeatTimer = ResettableTimer(isRepeating: true, debugName: "Send Heartbeat") { + + self.heartbeatTimer = ResettableTimer(isRepeating: true, debugName: Bundle.main.isDebug ? "Send Heartbeat" : nil) { Logger.transport.debug("💓 [Heartbeat] Sending periodic heartbeat") try? await self.sendHeartbeat() } @@ -722,7 +723,7 @@ extension AccessoryManager { // We can send heartbeats for older versions just fine, but only 2.7.4 and up will respond with // a definite queueStatus packet. if self.checkIsVersionSupported(forVersion: "2.7.4") { - self.heartbeatResponseTimer = ResettableTimer(isRepeating: false, debugName: "Heartbeat Timeout") { @MainActor in + self.heartbeatResponseTimer = ResettableTimer(isRepeating: false, debugName: Bundle.main.isDebug ? "Heartbeat Timeout" : nil) { @MainActor in Logger.transport.error("💓 [Heartbeat] Connection Timeout: Did not receive a packet after heartbeat.") // If we're in the middle of a connection cancel it. await self.connectionStepper?.cancel() diff --git a/Meshtastic/Accessory/Helpers/ManualConnectionList.swift b/Meshtastic/Accessory/Helpers/ManualConnectionList.swift new file mode 100644 index 00000000..665573a9 --- /dev/null +++ b/Meshtastic/Accessory/Helpers/ManualConnectionList.swift @@ -0,0 +1,61 @@ +// +// ManualConnectionList.swift +// Meshtastic +// +// Created by jake on 10/26/25. +// + +import Foundation + +// Maintains an observable list of devices that's backed by UserDefaults +public class ManualConnectionList: ObservableObject { + static let shared = ManualConnectionList() + + @Published private var _list: [Device] + + private init() { + _list = UserDefaults.manualConnections + } + + var connectionsList: [Device] { + get { + return _list + } + } + + func insert(device: Device) { + // Don't insert if already there + guard !_list.contains(where: {$0.id == device.id}) else { + return + } + + // Add the new entry + var list = _list + list.append(device) + _list = list + UserDefaults.manualConnections = list + } + + func updateDevice(deviceId: UUID, key: WritableKeyPath, value: T) where T: Equatable { + var list = _list + if let deviceIndex = list.firstIndex(where: {$0.id == deviceId}) { + list[deviceIndex][keyPath: key] = value + _list = list + UserDefaults.manualConnections = list + } + } + + func remove(device: Device) { + var list = _list + list.removeAll(where: {$0.id == device.id}) + _list = list + UserDefaults.manualConnections = list + } + + func remove(atOffsets: IndexSet) { + var list = _list + list.remove(atOffsets: atOffsets) + _list = list + UserDefaults.manualConnections = list + } +} diff --git a/Meshtastic/Accessory/Protocols/Connection.swift b/Meshtastic/Accessory/Protocols/Connection.swift index afc087c5..27758b84 100644 --- a/Meshtastic/Accessory/Protocols/Connection.swift +++ b/Meshtastic/Accessory/Protocols/Connection.swift @@ -31,7 +31,7 @@ enum ConnectionEvent { case disconnected(shouldReconnect: Bool) } -enum ConnectionState: Equatable { +enum ConnectionState: Equatable, Codable { case disconnected case connecting case connected diff --git a/Meshtastic/Accessory/Protocols/Device.swift b/Meshtastic/Accessory/Protocols/Device.swift index 844eff31..d14b349e 100644 --- a/Meshtastic/Accessory/Protocols/Device.swift +++ b/Meshtastic/Accessory/Protocols/Device.swift @@ -7,7 +7,8 @@ import Foundation -struct Device: Identifiable, Hashable { +struct Device: Identifiable, Hashable, Codable, CustomStringConvertible { + let id: UUID var name: String var transportType: TransportType @@ -23,10 +24,9 @@ struct Device: Identifiable, Hashable { var connectionState: ConnectionState var wasRestored: Bool = false + var isManualConnection: Bool = false - var connectionDetails: String? - - init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, connectionDetails: String? = nil, wasRestored: Bool = false) { + init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, wasRestored: Bool = false, isManualConnection: Bool = false) { self.id = id self.name = name self.transportType = transportType @@ -35,7 +35,7 @@ struct Device: Identifiable, Hashable { self.rssi = rssi self.num = num self.wasRestored = wasRestored - self.connectionDetails = connectionDetails + self.isManualConnection = isManualConnection } var rssiString: String { @@ -57,4 +57,16 @@ struct Device: Identifiable, Hashable { } } + var description: String { + switch (shortName, longName) { + case (let shortName?, let longName?): // Both shortName and longName are non-nil + return "\(longName) (\(shortName))" + case (let shortName?, nil): // shortName is non-nil, longName is nil + return "\(shortName)" + case (nil, let longName?): // shortName is nil, longName is non-nil + return "\(longName)" + default: // Both are nil + return "Device(id: \(id))" + } + } } diff --git a/Meshtastic/Accessory/Protocols/Transport.swift b/Meshtastic/Accessory/Protocols/Transport.swift index 0c5cce08..55fa8545 100644 --- a/Meshtastic/Accessory/Protocols/Transport.swift +++ b/Meshtastic/Accessory/Protocols/Transport.swift @@ -9,7 +9,7 @@ import Foundation import CommonCrypto import SwiftUI -enum TransportType: String, CaseIterable { +enum TransportType: String, CaseIterable, Codable { case ble = "BLE" case tcp = "TCP" case serial = "Serial" @@ -53,14 +53,15 @@ protocol Transport { var requiresPeriodicHeartbeat: Bool { get } var supportsManualConnection: Bool { get } - func manuallyConnect(withConnectionString: String) async throws + func device(forManualConnection: String) -> Device? + func manuallyConnect(toDevice: Device) async throws } // Used to make stable-ish ID's for accessories that don't have a UUID extension String { - func toUUIDFormatHash() -> UUID? { + func toUUIDFormatHash() -> UUID { // Convert string to data - guard let data = self.data(using: .utf8) else { return nil } + let data = self.data(using: .utf8) ?? Data() // Create buffer for SHA-256 hash (32 bytes) var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift index ea4a22b8..aa1a32d4 100644 --- a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift @@ -403,7 +403,11 @@ class BLETransport: Transport { } - func manuallyConnect(withConnectionString: String) async throws { + func device(forManualConnection: String) -> Device? { + return nil + } + + func manuallyConnect(toDevice: Device) async throws { Logger.transport.error("🛜 [BLE] This transport does not support manual connections") } diff --git a/Meshtastic/Accessory/Transports/Serial/SerialTransport.swift b/Meshtastic/Accessory/Transports/Serial/SerialTransport.swift index 920dd538..b34b1884 100644 --- a/Meshtastic/Accessory/Transports/Serial/SerialTransport.swift +++ b/Meshtastic/Accessory/Transports/Serial/SerialTransport.swift @@ -31,7 +31,7 @@ class SerialTransport: Transport { while !Task.isCancelled { let ports = self.getSerialPorts() for port in ports { - let id = port.toUUIDFormatHash() ?? UUID() + let id = port.toUUIDFormatHash() if !portsAlreadyNotified.contains(port) { Logger.transport.info("🔱 [Serial] Port \(port, privacy: .public) found.") let newDevice = Device(id: id, @@ -45,9 +45,8 @@ class SerialTransport: Transport { for knownPort in portsAlreadyNotified where !ports.contains(knownPort) { // Previosuly seen port is no longer available Logger.transport.info("🔱 [Serial] Port \(knownPort, privacy: .public) is no longer connected.") - if let uuid = knownPort.toUUIDFormatHash() { - cont.yield(.deviceLost(uuid)) - } + let uuid = knownPort.toUUIDFormatHash() + cont.yield(.deviceLost(uuid)) portsAlreadyNotified.removeAll(where: {$0 == knownPort}) } try? await Task.sleep(for: .seconds(5)) @@ -118,7 +117,11 @@ class SerialTransport: Transport { return SerialConnection(path: device.identifier) } - func manuallyConnect(withConnectionString: String) async throws { + func device(forManualConnection: String) -> Device? { + return nil + } + + func manuallyConnect(toDevice: Device) async throws { Logger.transport.error("🔱 [USB] This transport does not support manual connections") } } diff --git a/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift b/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift index 787d257c..b7da7436 100644 --- a/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift +++ b/Meshtastic/Accessory/Transports/TCP/TCPTransport.swift @@ -73,7 +73,7 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe let ip = service.ipv4Address ?? "Unknown IP" // Use a mishmash of things and hash for stable? ID. - let idString = "\(service.name):\(host):\(ip):\(port)".toUUIDFormatHash() ?? UUID() + let idString = "\(service.name):\(host):\(ip):\(port)".toUUIDFormatHash() // Save the resolved service locally for later services[service.name] = ResolvedService(id: idString, service: service, host: host, port: port) @@ -97,8 +97,7 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe let device = Device(id: idString, name: name, transportType: .tcp, - identifier: "\(host):\(port)", - connectionDetails: "\(ip):\(port)") + identifier: "\(host):\(port)") continuation?.yield(.deviceFound(device)) } @@ -151,16 +150,56 @@ class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDe } } - func manuallyConnect(withConnectionString: String) async throws { - let hashedIdentifier = withConnectionString.toUUIDFormatHash() ?? UUID() - let manualDevice = Device(id: hashedIdentifier, - name: "\(withConnectionString) (Manual)", - transportType: .tcp, identifier: withConnectionString) - try await AccessoryManager.shared.connect(to: manualDevice) + func device(forManualConnection connectionString: String) -> Device? { + let parts = connectionString.split(separator: ":") + var identifier: String + + switch parts.count { + case 1: + // host & default port + identifier = "\(parts[0]):4403" + + case 2: + // host & port + if parts[1].isValidTCPPort { + identifier = "\(parts[0]):\(parts[1])" + } + fallthrough + + default: + return nil + } + let hashedIdentifier = identifier.toUUIDFormatHash() + return Device(id: hashedIdentifier, + name: "\(connectionString) (Manual)", + transportType: .tcp, + identifier: connectionString, + isManualConnection: true) + } + + func manuallyConnect(toDevice device: Device) async throws { + try await AccessoryManager.shared.connect(to: device) } } +extension StringProtocol { + var isValidTCPPort: Bool { + // Check if the string is non-empty and contains only digits + guard !isEmpty, allSatisfy({ $0.isNumber }) else { + return false + } + + // Parse the string to an integer + guard let port = Int(self) else { + return false // Fails if the string can't be converted to an integer + } + + // Check if the port is in the valid TCP range (0–65535) + return port >= 0 && port <= 65535 + } +} + extension NetService { var ipv4Address: String? { for addressData in addresses ?? [] { diff --git a/Meshtastic/Extensions/Bundle.swift b/Meshtastic/Extensions/Bundle.swift index 0bb62acf..9f710bb7 100644 --- a/Meshtastic/Extensions/Bundle.swift +++ b/Meshtastic/Extensions/Bundle.swift @@ -23,4 +23,12 @@ extension Bundle { public var isTestFlight: Bool { return appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" } + + public var isDebug: Bool { + #if DEBUG + return true + #else + return false + #endif + } } diff --git a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift index 8d7962b4..5916567c 100644 --- a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift @@ -38,9 +38,8 @@ extension ChannelEntity { } func unreadMessages(context: NSManagedObjectContext) -> Int { - let context = PersistenceController.shared.container.viewContext let fetchRequest = messageFetchRequest - fetchRequest.sortDescriptors = [] // sort is irrelvant. + fetchRequest.sortDescriptors = [] // sort is irrelevant. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) return (try? context.count(for: fetchRequest)) ?? 0 diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift index e7abb191..5c06ecf2 100644 --- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift @@ -38,4 +38,39 @@ extension MessageEntity { } return false // First message will have no timestamp } + + func relayDisplay() -> String? { + + guard self.relayNode != 0 else { return nil } + let context = PersistenceController.shared.container.viewContext + + let relaySuffix = Int64(self.relayNode & 0xFF) + let request: NSFetchRequest = UserEntity.fetchRequest() + request.predicate = NSPredicate(format: "(num & 0xFF) == %lld", relaySuffix) + + do { + let users = try context.fetch(request) + + // If exactly one match is found, return its name + if users.count == 1, let name = users.first?.longName, !name.isEmpty { + return "\(name)" + } + + // If no exact match, find the node with the smallest hopsAway + if let closestNode = users.min(by: { lhs, rhs in + guard let lhsHops = lhs.userNode?.hopsAway, let rhsHops = rhs.userNode?.hopsAway else { + return false + } + return lhsHops < rhsHops + }), let name = closestNode.longName, !name.isEmpty { + return "\(name)" + } + + // Fallback to hex node number if no matches + return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF)) + + } catch { + return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF)) + } + } } diff --git a/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift b/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift index 136b64cb..c9e06d88 100644 --- a/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MyInfoEntityExtension.swift @@ -30,7 +30,7 @@ extension MyInfoEntity { func unreadMessages(context: NSManagedObjectContext) -> Int { // Returns the count of unread *channel* messages let fetchRequest = messageFetchRequest - fetchRequest.sortDescriptors = [] // sort is irrelvant. + fetchRequest.sortDescriptors = [] // sort is irrelevant. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) return (try? context.count(for: fetchRequest)) ?? 0 diff --git a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift index 830ac772..fbf55f5a 100644 --- a/Meshtastic/Extensions/CoreData/UserEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/UserEntityExtension.swift @@ -56,7 +56,7 @@ extension UserEntity { guard self.lastMessage != nil || skipLastMessageCheck else { return 0; } let fetchRequest = messageFetchRequest - fetchRequest.sortDescriptors = [] // sort is irrelvant. + fetchRequest.sortDescriptors = [] // sort is irrelevant. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")]) return (try? context.count(for: fetchRequest)) ?? 0 diff --git a/Meshtastic/Extensions/UserDefaults.swift b/Meshtastic/Extensions/UserDefaults.swift index d5a58e13..751ddc17 100644 --- a/Meshtastic/Extensions/UserDefaults.swift +++ b/Meshtastic/Extensions/UserDefaults.swift @@ -79,6 +79,7 @@ extension UserDefaults { case showDeviceOnboarding case usageDataAndCrashReporting case autoconnectOnDiscovery + case manualConnections case testIntEnum } @@ -178,6 +179,36 @@ extension UserDefaults { @UserDefault(.testIntEnum, defaultValue: .one) static var testIntEnum: TestIntEnum + + static var manualConnections: [Device] { + get { + // Retrieve data from UserDefaults + guard let data = UserDefaults.standard.data(forKey: Keys.manualConnections.rawValue) else { + return [] + } + + // Decode the Data back to [Device] + do { + let decoder = JSONDecoder() + let devices = try decoder.decode([Device].self, from: data) + return devices + } catch { + return [] + } + } + set { + do { + // Encode the [Device] to Data + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + + // Store the Data in UserDefaults + UserDefaults.standard.set(data, forKey: Keys.manualConnections.rawValue) + } catch { + print("Failed to encode manualConnections: \(error)") + } + } + } } enum TestIntEnum: Int, Decodable { diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index e3aa3252..1e68896b 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -624,6 +624,7 @@ func adminResponseAck (packet: MeshPacket, context: NSManagedObjectContext) { fetchedMessage[0].ackError = Int32(RoutingError.none.rawValue) fetchedMessage[0].receivedACK = true fetchedMessage[0].realACK = true + fetchedMessage[0].relayNode = Int64(packet.relayNode) fetchedMessage[0].ackSNR = packet.rxSnr if fetchedMessage[0].fromUser != nil { fetchedMessage[0].fromUser?.objectWillChange.send() @@ -699,9 +700,11 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana fetchedMessage[0].realACK = true } } + fetchedMessage[0].relayNode = Int64(packet.relayNode) fetchedMessage[0].ackError = Int32(routingMessage.errorReason.rawValue) if routingMessage.errorReason == Routing.Error.none { fetchedMessage[0].receivedACK = true + fetchedMessage[0].relays += 1 } fetchedMessage[0].ackSNR = packet.rxSnr @@ -944,6 +947,9 @@ func textMessageAppPacket( } else { newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970) } + if packet.relayNode != 0 { + newMessage.relayNode = Int64(packet.relayNode) + } newMessage.receivedACK = false newMessage.snr = packet.rxSnr newMessage.rssi = packet.rxRssi @@ -983,6 +989,7 @@ func textMessageAppPacket( newMessage.pkiEncrypted = true newMessage.publicKey = packet.publicKey } + /// Check for key mismatch if let nodeKey = newMessage.fromUser?.publicKey { if newMessage.toUser != nil && packet.pkiEncrypted && !packet.publicKey.isEmpty { diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents index b5e4a81e..a6e5465f 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -164,6 +164,8 @@ + + diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index 3f758329..ad9116b9 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -465,13 +465,17 @@ func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) mutablePositions.add(position) fetchedNode[0].id = Int64(packet.from) fetchedNode[0].num = Int64(packet.from) - if positionMessage.time > 0 { + + // Update the node's lastHeard. + // Some misconfigured nodes will broadcast position packets that claim GPS timestamps in the future. When updating lastHeard, don't use any future timestamps: fallback to using rxTime or Date() instead. + if positionMessage.time > 0 && (Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) <= Date()) { fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) } else if packet.rxTime > 0 { fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) } else { fetchedNode[0].lastHeard = Date() } + fetchedNode[0].snr = packet.rxSnr fetchedNode[0].rssi = packet.rxRssi fetchedNode[0].viaMqtt = packet.viaMqtt diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift index 7a264aa6..bb43ae04 100644 --- a/Meshtastic/Views/Connect/Connect.swift +++ b/Meshtastic/Views/Connect/Connect.swift @@ -26,8 +26,7 @@ struct Connect: View { @State var isUnsetRegion = false @State var invalidFirmwareVersion = false @State var liveActivityStarted = false - @State var presentingSwitchPreferredPeripheral = false - @State var selectedPeripherialId = "" + @ObservedObject var manualConnections = ManualConnectionList.shared var body: some View { NavigationStack { @@ -64,22 +63,10 @@ struct Connect: View { } Text("Connection Name").font(.callout)+Text(": \(connectedDevice.name.addingVariationSelectors)") .font(.callout).foregroundColor(Color.gray) - HStack { + HStack(alignment: .firstTextBaseline) { + TransportIcon(transportType: connectedDevice.transportType) if connectedDevice.transportType == .ble { - // baseline aligned looks better for the signal meter - HStack(alignment: .firstTextBaseline) { - TransportIcon(transportType: connectedDevice.transportType) - connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) } - } - } else if connectedDevice.transportType == .tcp { - // Not baseline aligned looks better for the connection string - HStack { - TransportIcon(transportType: connectedDevice.transportType) - Text("\(connectedDevice.connectionDetails ?? connectedDevice.identifier)") - .foregroundColor(.gray) - } - } else { - TransportIcon(transportType: connectedDevice.transportType) + connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) } } Spacer() } @@ -276,68 +263,33 @@ struct Connect: View { .textCase(nil) if !(accessoryManager.isConnected || accessoryManager .isConnecting) { - Section(header: HStack { - Text("Available Radios").font(.title) - Spacer() - ManualConnectionMenu() - }) { - ForEach(accessoryManager.devices.sorted(by: { $0.name < $1.name })) { device in - HStack { - if UserDefaults.preferredPeripheralId == device.id.uuidString { - Image(systemName: "star.fill") - .imageScale(.large).foregroundColor(.yellow) - .padding(.trailing) - } else { - Image(systemName: "circle.fill") - .imageScale(.large).foregroundColor(.gray) - .padding(.trailing) - } - VStack(alignment: .leading) { - Button(action: { - if UserDefaults.preferredPeripheralId.count > 0 && device.id.uuidString != UserDefaults.preferredPeripheralId { - if accessoryManager.allowDisconnect { - Task { try await accessoryManager.disconnect() } - } - presentingSwitchPreferredPeripheral = true - selectedPeripherialId = device.id.uuidString - } else { - Task { - try? await accessoryManager.connect(to: device) - } - } - }) { - Text(device.name).font(.callout) - } - // Show transport type - HStack { - TransportIcon(transportType: device.transportType) - if device.transportType == .tcp { - // Show IP and Port - Text("\(device.connectionDetails ?? device.identifier)") - .foregroundColor(.gray) - } - } - } - Spacer() - VStack { - device.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0) } - } - }.padding([.bottom, .top]) - } - } - .confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) { - Button("Connect to new radio?", role: .destructive) { - UserDefaults.preferredPeripheralId = selectedPeripherialId - UserDefaults.preferredPeripheralNum = 0 - if accessoryManager.allowDisconnect { - Task { try await accessoryManager.disconnect() } + Group { + Section(header: HStack { + Text("Available Radios").font(.title) + Spacer() + ManualConnectionMenu() + }) { + ForEach(accessoryManager.devices.sorted(by: { $0.name < $1.name })) { device in + DeviceConnectRow(device: device) } - clearCoreDataDatabase(context: context, includeRoutes: false) - clearNotifications() - if let radio = accessoryManager.devices.first(where: { $0.id.uuidString == selectedPeripherialId }) { - Task { - try await accessoryManager.connect(to: radio) + } + if manualConnections.connectionsList.count > 0 { + Section(header: Text("Manual Connections").font(.title)) { + ForEach(manualConnections.connectionsList) { device in + DeviceConnectRow(device: device) +#if targetEnvironment(macCatalyst) + .contextMenu { + Button { + manualConnections.remove(device: device) + } label: { + Label("Delete", systemImage: "trash") + } + } +#endif + }.onDelete { offsets in + manualConnections.remove(atOffsets: offsets) } + } } } @@ -491,6 +443,10 @@ struct TransportIcon: View { } struct ManualConnectionMenu: View { + + @EnvironmentObject var accessoryManager: AccessoryManager + @Environment(\.managedObjectContext) var context + private struct IterableTransport: Identifiable { let id: UUID let icon: Image @@ -509,7 +465,9 @@ struct ManualConnectionMenu: View { @State private var selectedTransport: IterableTransport? @State private var showAlert: Bool = false @State private var connectionString = "" - + @State var presentingSwitchPreferredPeripheral = false + @State var deviceForManualConnection: Device? + var body: some View { Menu { ForEach(transports) { transport in @@ -539,11 +497,114 @@ struct ManualConnectionMenu: View { Button("OK", action: { if !connectionString.isEmpty { - Task { - try await selectedTransport.transport.manuallyConnect(withConnectionString: connectionString) + if let device = selectedTransport.transport.device(forManualConnection: connectionString) { + if UserDefaults.preferredPeripheralId == device.id.uuidString { + Task { + try await selectedTransport.transport.manuallyConnect(toDevice: device) + } + } else { + deviceForManualConnection = device + presentingSwitchPreferredPeripheral = true + } } } }) - } + }.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) { + Button("Connect to new radio?", role: .destructive) { + if let device = deviceForManualConnection { + UserDefaults.preferredPeripheralId = device.id.uuidString + UserDefaults.preferredPeripheralNum = 0 + if accessoryManager.allowDisconnect { + Task { try await accessoryManager.disconnect() } + } + clearCoreDataDatabase(context: context, includeRoutes: false) + clearNotifications() + Task { + try await selectedTransport?.transport.manuallyConnect(toDevice: device) + } + + // Clean up just in case + deviceForManualConnection = nil + } + } + } } } + +struct DeviceConnectRow: View { + @Environment(\.managedObjectContext) var context + @EnvironmentObject var accessoryManager: AccessoryManager + @State var presentingSwitchPreferredPeripheral = false + let device: Device + + var body: some View { + HStack { + if UserDefaults.preferredPeripheralId == device.id.uuidString { + Image(systemName: "star.fill") + .imageScale(.large).foregroundColor(.yellow) + .padding(.trailing) + } else { + Image(systemName: "circle.fill") + .imageScale(.large).foregroundColor(.gray) + .padding(.trailing) + } + VStack(alignment: .leading) { + Button(action: { + if UserDefaults.preferredPeripheralId.count > 0 && device.id.uuidString != UserDefaults.preferredPeripheralId { + if accessoryManager.allowDisconnect { + Task { try await accessoryManager.disconnect() } + } + presentingSwitchPreferredPeripheral = true + } else { + Task { + try? await accessoryManager.connect(to: device) + } + } + }) { + Text(device.name).font(.callout) + } + // Show transport type +#if !targetEnvironment(macCatalyst) + HStack(alignment: .center){ + TransportIcon(transportType: device.transportType) + if device.isManualConnection && (device.longName != nil || device.shortName != nil) { + VStack (alignment: .leading) { + Text("Last seen device:") + Text("\(String(describing: device))") + } + } + }.padding(.top, 3.0) +#else + //Different alignment for Mac + HStack(alignment: .firstTextBaseline){ + TransportIcon(transportType: device.transportType) + if device.isManualConnection && (device.longName != nil || device.shortName != nil) { + Text("Last seen device: \(String(describing: device))") + } + } +#endif + } + Spacer() + VStack { + device.getSignalStrength().map { + SignalStrengthIndicator(signalStrength: $0) + } + } + }.padding([.bottom, .top]) + .confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) { + Button("Connect to new radio?", role: .destructive) { + UserDefaults.preferredPeripheralId = device.id.uuidString + UserDefaults.preferredPeripheralNum = 0 + if accessoryManager.allowDisconnect { + Task { try await accessoryManager.disconnect() } + } + clearCoreDataDatabase(context: context, includeRoutes: false) + clearNotifications() + Task { + try await accessoryManager.connect(to: device) + } + } + } + } +} + diff --git a/Meshtastic/Views/Messages/MessageContextMenuItems.swift b/Meshtastic/Views/Messages/MessageContextMenuItems.swift index 33554de7..63104320 100644 --- a/Meshtastic/Views/Messages/MessageContextMenuItems.swift +++ b/Meshtastic/Views/Messages/MessageContextMenuItems.swift @@ -11,6 +11,7 @@ struct MessageContextMenuItems: View { let isCurrentUser: Bool @Binding var isShowingDeleteConfirmation: Bool let onReply: () -> Void + @State var relayDisplay: String? = nil var body: some View { VStack { @@ -19,6 +20,14 @@ struct MessageContextMenuItems: View { } Text("Channel") + Text(": \(message.channel)") } + .onAppear { + DispatchQueue.global(qos: .userInitiated).async { + let result = message.relayDisplay() + DispatchQueue.main.async { + relayDisplay = result + } + } + } Menu("Tapback") { ForEach(Tapbacks.allCases) { tb in @@ -59,12 +68,27 @@ struct MessageContextMenuItems: View { } Menu("Message Details") { + // Precompute values to avoid executing non-View code inside the ViewBuilder + let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) + let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) + let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) + + // Compute a relay display string if relayNode is present + + VStack { - let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp)) - Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))").foregroundColor(.gray) + Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))") + .foregroundColor(.gray) } - if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 { + if let relayDisplay { + let prefix = message.realACK ? "Ack Relay: " : "Relay: " + Text(prefix + relayDisplay) + .foregroundColor(relayDisplay.contains("Node ") ? .gray : .primary) + .font(relayDisplay.contains("Node ") ? .caption : .body) + } + + if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 { VStack { Text("SNR \(String(format: "%.2f", message.snr)) dB") Text("RSSI \(String(format: "%.2f", message.rssi)) dBm") @@ -74,29 +98,29 @@ struct MessageContextMenuItems: View { Text("Hops Away \(message.fromUser?.userNode?.hopsAway ?? 0)") } } + if message.relays != 0 && message.realACK == false { + Text("Relayed by \(message.relays) \(message.relays == 1 ? "node" : "nodes")") + } if isCurrentUser && message.receivedACK { VStack { - Text("Received Ack") + Text(": \(message.receivedACK ? "✔️" : "")") - Text("Recipient Ack") + Text(": \(message.realACK ? "✔️" : "")") + Text("Received Ack: \(message.receivedACK ? "✔️" : "")") + Text("Recipient Ack: \(message.realACK ? "✔️" : "")") } } else if isCurrentUser && message.ackError == 0 { - // Empty Error Text("Waiting") } else if isCurrentUser && message.ackError > 0 { let ackErrorVal = RoutingError(rawValue: Int(message.ackError)) Text("\(ackErrorVal?.display ?? "Empty Ack Error")") .fixedSize(horizontal: false, vertical: true) } + if isCurrentUser { - VStack { - let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp)) - let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date()) - if ackDate >= sixMonthsAgo! { - Text("Ack Time: \(ackDate.formattedDate(format: MessageText.timeFormatString))") - .foregroundColor(.gray) - } + if let sixMonthsAgo, ackDate >= sixMonthsAgo { + Text("Ack Time: \(ackDate.formattedDate(format: MessageText.timeFormatString))") + .foregroundColor(.gray) } } + if message.ackSNR != 0 { VStack { Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB") diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 707ee1da..f1fd931f 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -47,7 +47,7 @@ struct MeshMapContent: MapContent { @MapContentBuilder var positionAnnotations: some MapContent { ForEach(positions, id: \.id) { position in - /// Apply favorits filter and don't show ignored nodes + /// Apply favorites filter and don't show ignored nodes if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) { let coordinateForNodePin: CLLocationCoordinate2D = if position.isPreciseLocation { // Precise location: place node pin at actual location.