From 486b4c1821df53261b876805c98b0fd84cfaa3d0 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Fri, 24 Oct 2025 19:55:40 -0400 Subject: [PATCH] Keep list of previous manual connections --- Localizable.xcstrings | 18 +- .../AccessoryManager+Connect.swift | 21 ++- .../Accessory Manager/AccessoryManager.swift | 5 +- .../Accessory/Protocols/Connection.swift | 2 +- Meshtastic/Accessory/Protocols/Device.swift | 7 +- .../Accessory/Protocols/Transport.swift | 9 +- .../Bluetooth Low Energy/BLETransport.swift | 6 +- .../Transports/Serial/SerialTransport.swift | 13 +- .../Transports/TCP/TCPTransport.swift | 54 +++++- Meshtastic/Extensions/Bundle.swift | 8 + Meshtastic/Extensions/UserDefaults.swift | 31 ++++ Meshtastic/Views/Connect/Connect.swift | 168 ++++++++++++------ 12 files changed, 252 insertions(+), 90 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 9bbbd0bd..5ec975ae 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3905,10 +3905,6 @@ } } }, - "Anonymous Usage and Crash data" : { - "comment" : "A description of how the app collects and uses data about its usage and crashes. It emphasizes that this data is anonymous and non-personally identifiable.", - "isCommentAutoGenerated" : true - }, "Any missed messages will be delivered again." : { "localizations" : { "it" : { @@ -20445,6 +20441,9 @@ } } } + }, + "Manual Connections" : { + }, "Map Data" : { "localizations" : { @@ -20930,6 +20929,10 @@ } } }, + "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" : { "localizations" : { "de" : { @@ -40176,6 +40179,9 @@ } } } + }, + "User Privacy" : { + }, "User Uploaded" : { "comment" : "Data source label for user uploaded files", @@ -41040,10 +41046,6 @@ }, "Waypoints" : { - }, - "We 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" : "A description of how the app collects and uses user data. Includes a link to the app settings.", - "isCommentAutoGenerated" : true }, "Weather Conditions" : { "localizations" : { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index df627fbe..259b9c7b 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,11 @@ 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 + if device.isManualConnection { + self.saveManualConnection(device: device) + } } // Step 8: Update UI and status to connected @@ -214,6 +224,15 @@ extension AccessoryManager { // All done, one way or another, clean up self.connectionStepper = nil } + + fileprivate func saveManualConnection(device: Device) { + var manualConnections = UserDefaults.manualConnections + + if manualConnections.first(where: {$0.id == device.id}) == nil { + manualConnections.append(device) + UserDefaults.manualConnections = manualConnections + } + } } // Sequentially stepped tasks diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 3c507a03..a723b54d 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -703,7 +703,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() } @@ -711,7 +712,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/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 03ae1d1d..a5c900f0 100644 --- a/Meshtastic/Accessory/Protocols/Device.swift +++ b/Meshtastic/Accessory/Protocols/Device.swift @@ -7,7 +7,7 @@ import Foundation -struct Device: Identifiable, Hashable { +struct Device: Identifiable, Hashable, Codable { let id: UUID var name: String var transportType: TransportType @@ -23,7 +23,9 @@ struct Device: Identifiable, Hashable { var connectionState: ConnectionState var wasRestored: Bool = false - init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil, num: Int64? = nil, wasRestored: Bool = false) { + var isManualConnection: 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 @@ -32,6 +34,7 @@ struct Device: Identifiable, Hashable { self.rssi = rssi self.num = num self.wasRestored = wasRestored + self.isManualConnection = isManualConnection } var rssiString: String { 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 d753fc76..af80beb5 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) @@ -134,16 +134,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/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/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift index 8915d9ff..6ac3106a 100644 --- a/Meshtastic/Views/Connect/Connect.swift +++ b/Meshtastic/Views/Connect/Connect.swift @@ -26,8 +26,6 @@ struct Connect: View { @State var isUnsetRegion = false @State var invalidFirmwareVersion = false @State var liveActivityStarted = false - @State var presentingSwitchPreferredPeripheral = false - @State var selectedPeripherialId = "" var body: some View { NavigationStack { @@ -264,60 +262,24 @@ 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 - TransportIcon(transportType: device.transportType) - } - 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 UserDefaults.manualConnections.count > 0 { + Section(header: Text("Manual Connections").font(.title)) { + ForEach(UserDefaults.manualConnections) { device in + DeviceConnectRow(device: device) + }.onDelete { offsets in + var list = UserDefaults.manualConnections + list.remove(atOffsets: offsets) + UserDefaults.manualConnections = list } } } @@ -472,6 +434,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 @@ -490,7 +456,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 @@ -520,11 +488,93 @@ 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 + var 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 + TransportIcon(transportType: device.transportType) + } + 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) + } + } + } } }