From f99e50f47bd7d280402b47a1e58b37457b50d7d3 Mon Sep 17 00:00:00 2001 From: Garth Vander Houwen Date: Tue, 2 Sep 2025 22:30:43 -0700 Subject: [PATCH] User Friendly BLE Transport errors (#1365) * Remove Stale keys * update debug logo * 15 Second heartbeat * Onboarding updates for network and BLE * Add transport error enum * Customize BLE errors * Add pin errors * Error cleanup * Override error text and reconnection logic for 4 BLE errors * Update Meshtastic/Views/Onboarding/DeviceOnboarding.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Switch back to onFirstAppear * Update Meshtastic/Views/Onboarding/DeviceOnboarding.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Friendly error for peer removed pairing information * use radio in all custom BLE errors * Update info.plist --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Localizable.xcstrings | 200 ------------------ .../AccessoryManager+Connect.swift | 2 +- .../Accessory Manager/AccessoryManager.swift | 35 ++- .../Bluetooth Low Energy/BLEConnection.swift | 74 +++---- .../Bluetooth Low Energy/BLETransport.swift | 50 +---- Meshtastic/Info.plist | 4 +- .../Views/Messages/ChannelMessageList.swift | 1 - .../Views/Onboarding/DeviceOnboarding.swift | 38 +++- 8 files changed, 97 insertions(+), 307 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 1f915f34..ca088018 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -23126,71 +23126,6 @@ } } }, - "Node info received for: %@" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Knoteninformation empfangen für: %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Information du noeud reçue pour : %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מידע אודות מכשיר התקבל: %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ricevute informazioni sul nodo per: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ノード情報を受信しました: %@" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odebrano informacje o węźle dla: %@" - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nodinformation mottagen för: %@" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Информације о чвору примљене за: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Node info received for: %@" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "Node info received for: %@" - } - } - } - }, "Node Map" : { "localizations" : { "de" : { @@ -26095,71 +26030,6 @@ } } }, - "Position Packet received from node: %@" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Position empfangen von Knoten: %@" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Paquet de la position reçu du noeud : %@" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הודעת מיקום התקבלו מ-%@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Posizione Pacchetto ricevuto dal nodo: %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "ノード %@ から位置パケットを受信しました" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odebrano pakiet pozycji od węzła: %@" - } - }, - "se" : { - "stringUnit" : { - "state" : "translated", - "value" : "Positionspaket mottaget från nod: %@" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Пакет позиције примљен од чвора: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Position Packet received from node: %@" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "Position Packet received from node: %@" - } - } - } - }, "Position Sent" : { "localizations" : { "de" : { @@ -35713,41 +35583,6 @@ } } }, - "The most recent public key for this node does not match the previously recorded key. You can delete the node and let it exchange keys again, but this also may indicate a more serious security problem. Contact the user through another trusted channel to determine if the key change was due to a factory reset or other intentional action." : { - "extractionState" : "stale", - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "La chiave pubblica più recente di questo nodo non corrisponde alla chiave registrata in precedenza. È possibile eliminare il nodo e fargli scambiare nuovamente le chiavi, ma questo potrebbe indicare un problema di sicurezza più serio. Contattare l'utente attraverso un altro canale fidato per determinare se la modifica della chiave è dovuta a un reset di fabbrica o a un'altra azione intenzionale." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "このノードの最新の公開キーが以前記録されたキーと一致しません。ノードを削除して再度キー交換を行うことができますが、これはより深刻なセキュリティ問題を示している可能性もあります。信頼できる別のチャンネルを通じてユーザーに連絡し、キーの変更が工場リセットやその他の intentional action によるものかどうかを確認してください。" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Најновији јавни кључ за овај чвор се не подудара са претходно снимљеним кључем. Можете избрисати чвор и дозволити му да поново размени кључеве, али ово такође може указивати на озбиљнији безбедносни проблем. Контактирајте корисника преко другог поузданог канала како бисте утврдили да ли је промена кључа резултат фабричког ресетовања или друге намерне акције." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "该节点的最新公钥与之前记录的公钥不匹配。您可以删除该节点,让它重新交换公钥,但这也可能表明存在更严重的安全问题。通过其他可信渠道联系用户,以确定公钥更改是否是由于出厂重置或其他故意行为造成的。" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "此節點最新的公開金鑰與先前記錄的不符。您可以刪除此節點並讓它重新交換金鑰,但這也可能代表出現了更嚴重的安全問題。請透過其他可信的聯絡方式與該使用者確認金鑰變更是否因為恢復原廠設定或其他有意的操作所導致。" - } - } - } - }, "The packet is too large" : { "localizations" : { "de" : { @@ -35852,41 +35687,6 @@ } } }, - "The public key does not match the recorded key. You may delete the node and let it exchange keys again, but this may indicate a more serious security problem. Contact the user through another trusted channel, to determine if the key change was due to a factory reset or other intentional action." : { - "extractionState" : "stale", - "localizations" : { - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "La chiave pubblica non corrisponde alla chiave registrata. È possibile eliminare il nodo e fargli scambiare nuovamente le chiavi, ma questo potrebbe indicare un problema di sicurezza più serio. Contattare l'utente attraverso un altro canale fidato, per determinare se la modifica della chiave è dovuta a un reset di fabbrica o a un'altra azione intenzionale." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "公開キーが記録されたキーと一致しません。ノードを削除して再度キー交換を行うことができますが、これはより深刻なセキュリティ問題を示している可能性があります。信頼できる別のチャンネルを通じてユーザーに連絡し、キーの変更が工場リセットやその他の意図的な操作によるものかどうか確認してください。" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Јавни кључ се не подудара са снимљеним кључем. Можете избрисати чвор и дозволити му да поново размени кључеве, али ово може указивати на озбиљнији безбедносни проблем. Контактирајте корисника преко другог поузданог канала како бисте утврдили да ли је промена кључа резултат фабричког ресетовања или друге намерне акције." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "公钥与记录的公钥不匹配。您可以删除节点,让它重新交换公钥,但这可能表明存在更严重的安全问题。通过其他可信渠道联系用户,以确定公钥更改是否是由于出厂重置或其他故意行为造成的。" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "公開金鑰與原先記錄的不符。您可以刪除此節點並讓它重新交換金鑰,但這可能表示存在更嚴重的安全問題。請透過其他可信的聯絡方式與該使用者確認,此次金鑰變更是否因為恢復原廠設定或其他有意的操作所造成。" - } - } - } - }, "The region where you will be using your radios." : { "localizations" : { "it" : { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift index 8883bb8b..9f60b1ba 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Connect.swift @@ -66,7 +66,7 @@ extension AccessoryManager { UserDefaults.preferredPeripheralId = device.id.uuidString } } catch let error as CBError where error.code == .peerRemovedPairingInformation { - await self.connectionStepper?.cancelCurrentlyExecutingStep(withError: error, cancelFullProcess: true) + await self.connectionStepper?.cancelCurrentlyExecutingStep(withError: AccessoryError.coreBluetoothError(error), cancelFullProcess: true) } } diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index c94bc0ad..d9aa53b1 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -6,6 +6,7 @@ import Foundation import SwiftUI import MeshtasticProtobufs +import CoreBluetooth import OSLog import CocoaMQTT import Combine @@ -20,6 +21,8 @@ enum AccessoryError: Error, LocalizedError { case disconnected(String) case tooManyRetries case eventStreamCancelled + case coreBluetoothError(CBError) + case coreBluetoothATTError(CBATTError) var errorDescription: String? { switch self { @@ -34,13 +37,37 @@ enum AccessoryError: Error, LocalizedError { case .appError(let message): return "Application error: \(message)" case .timeout: - return "Timeout" + return "Connection Timeout" case .disconnected(let message): return "Disconnected: \(message)" case .tooManyRetries: return "Too Many Retries" case .eventStreamCancelled: return "Event stream cancelled" + case .coreBluetoothError(let cbError): + // Map specific CBError values to a more user-friendly message + switch cbError.code { + case .connectionTimeout: // 6 + return "The Bluetooth connection to the radio unexpectedly disconnected, it will automatically reconnect to the preferred radio when it comes back in range or is powered back on.".localized + case .peripheralDisconnected: // 7 + return "The Bluetooth connection to the radio was disconnected, it will automatically reconnect to the preferred radio when it is powered back on or finishes rebooting.".localized + case .peerRemovedPairingInformation: // 14 + return "The radio has deleted its stored pairing information, but your device has not. To resolve this, you must forget the radio under Settings > Bluetooth to clear the old, now invalid, pairing information.".localized + default: + // Fallback for other CBError codes + return "A Bluetooth error occurred: \(cbError.localizedDescription)" + } + case .coreBluetoothATTError(let attError): + // Map specific CBATTError values to a more user-friendly message + switch attError.code { + case .insufficientAuthentication: // 5 + return "Bluetooth \(attError.localizedDescription) Please try connecting again and check the BLE PIN carefully.".localized + case .insufficientEncryption: // 15 + return "Bluetooth \(attError.localizedDescription) Please try connecting again and check the BLE PIN carefully.".localized + default: + // Fallback for other CBError codes + return "A Bluetooth Attribute Protocol error occurred: \(attError.localizedDescription)" + } } } } @@ -349,14 +376,14 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { self.processFromRadio(fromRadio) Task { await self.heartbeatResponseTimer?.cancel(withReason: "Data packet received") - await self.heartbeatTimer?.reset(delay: .seconds(60.0)) + await self.heartbeatTimer?.reset(delay: .seconds(15.0)) } case .logMessage(let message): self.didReceiveLog(message: message) Task { await self.heartbeatResponseTimer?.cancel(withReason: "Log message packet received") - await self.heartbeatTimer?.reset(delay: .seconds(60.0)) + await self.heartbeatTimer?.reset(delay: .seconds(15.0)) } case .rssiUpdate(let rssi): @@ -698,7 +725,7 @@ extension AccessoryManager { } } } - await self.heartbeatTimer?.reset(delay: .seconds(60.0)) + await self.heartbeatTimer?.reset(delay: .seconds(15.0)) } } diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift index a4381433..b25d1eb7 100644 --- a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLEConnection.swift @@ -100,9 +100,21 @@ actor BLEConnection: Connection { if let error { // Inform the AccessoryManager of the error and intent to reconnect if shouldReconnect { - connectionStreamContinuation?.yield(.error(error)) + if let cbError = error as? CBError { + connectionStreamContinuation?.yield(.error(AccessoryError.coreBluetoothError(cbError))) + } else if let attError = error as? CBATTError { + connectionStreamContinuation?.yield(.error(AccessoryError.coreBluetoothATTError(attError))) + } else { + connectionStreamContinuation?.yield(.error(error)) + } } else { - connectionStreamContinuation?.yield(.errorWithoutReconnect(error)) + if let cbError = error as? CBError { + connectionStreamContinuation?.yield(.errorWithoutReconnect(AccessoryError.coreBluetoothError(cbError))) + } else if let attError = error as? CBATTError { + connectionStreamContinuation?.yield(.errorWithoutReconnect(AccessoryError.coreBluetoothATTError(attError))) + } else { + connectionStreamContinuation?.yield(.errorWithoutReconnect(error)) + } } } else { connectionStreamContinuation?.yield(.disconnected(shouldReconnect: shouldReconnect)) @@ -398,62 +410,32 @@ actor BLEConnection: Connection { } func handlePeripheralError(error: Error) async throws { + /// Explicit retries for a few specific errors where we want to re-connect, all other errors should not reconnect automatically var shouldReconnect = false switch error { + case let attError as CBATTError: + switch attError.code { + default: + // All CBATTErrors should not try and reconnect + Logger.transport.error("🛜 [BLEConnection] Disconnected with CBATTError code: \(attError.code.rawValue) - \(attError.localizedDescription)") + } case let cbError as CBError: switch cbError.code { - case .unknown: // 0 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown error.") - case .invalidParameters: // 1 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to invalid parameters.") - case .invalidHandle: // 2 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to invalid handle.") - case .notConnected: // 3 - Logger.transport.error("🛜 [BLEConnection] Disconnected because device was not connected.") - case .outOfSpace: // 4 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to out of space.") - case .operationCancelled: // 5 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to operation cancelled.") case .connectionTimeout: // 6 + // Happens when the node goes out of range or the shutdown or reset buttons are presses // Should disconnect, show error, and retry when re-advertised - Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection timeout.") + Logger.transport.error("🛜 [BLEConnection] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)") shouldReconnect = true case .peripheralDisconnected: // 7 - // Likely prompting for a PIN + // Happens when the node reboots or shuts down intentionally via the firmware or app // Should disconnect, show error, and retry when re-advertised - Logger.transport.error("🛜 [BLEConnection] Disconnected by peripheral.") + Logger.transport.error("🛜 [BLEConnection] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)") shouldReconnect = true - case .uuidNotAllowed: // 8 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to UUID not allowed.") - case .alreadyAdvertising: // 9 - Logger.transport.error("🛜 [BLEConnection] Disconnected because already advertising.") - case .connectionFailed: // 10 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection failure.") - case .connectionLimitReached: // 11 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection limit reached.") - case .unknownDevice, .unkownDevice: // 12 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown device.") - case .operationNotSupported: // 13 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to operation not supported.") - case .peerRemovedPairingInformation: // 14 - // Should disconnect and not retry - Logger.transport.error("🛜 [BLEConnection] Disconnected because peer removed pairing information.") - case .encryptionTimedOut: // 15 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to encryption timeout.") - case .tooManyLEPairedDevices: // 16 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to too many LE paired devices.") - - // leGatt cases are watchOS only - case .leGattExceededBackgroundNotificationLimit: // 17 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to exceeding LE GATT background notification limit.") - case .leGattNearBackgroundNotificationLimit: // 18 - Logger.transport.error("🛜 [BLEConnection] Disconnected due to nearing LE GATT background notification limit.") - - @unknown default: - Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown future error code: \(cbError.code.rawValue)") + default: + Logger.transport.error("🛜 [BLEConnection] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)") } case let otherError: - Logger.transport.error("🛜 [BLEConnection] Disconnected with non-CBError: \(otherError.localizedDescription)") + Logger.transport.error("🛜 [BLEConnection] Disconnected with non CBError or CBATTError: \(otherError.localizedDescription)") } // Inform the active connection that there was an error and it should disconnect diff --git a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift index 0f8e7333..4a6f1ca8 100644 --- a/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift +++ b/Meshtastic/Accessory/Transports/Bluetooth Low Energy/BLETransport.swift @@ -231,55 +231,19 @@ class BLETransport: Transport { switch error { case let cbError as CBError: switch cbError.code { - case .unknown: // 0 - Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown error.") - case .invalidParameters: // 1 - Logger.transport.error("🛜 [BLETransport] Disconnected due to invalid parameters.") - case .invalidHandle: // 2 - Logger.transport.error("🛜 [BLETransport] Disconnected due to invalid handle.") - case .notConnected: // 3 - Logger.transport.error("🛜 [BLETransport] Disconnected because device was not connected.") - case .outOfSpace: // 4 - Logger.transport.error("🛜 [BLETransport] Disconnected due to out of space.") - case .operationCancelled: // 5 - Logger.transport.error("🛜 [BLETransport] Disconnected due to operation cancelled.") case .connectionTimeout: // 6 + // Happens when the node goes out of range or the shutdown or reset buttons are presses // Should disconnect, show error, and retry when re-advertised - Logger.transport.error("🛜 [BLETransport] Disconnected due to connection timeout.") + Logger.transport.error("🛜 [BLETransport] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)") shouldReconnect = true case .peripheralDisconnected: // 7 - // Likely prompting for a PIN + // Happens when the node reboots or shuts down intentionally via the firmware or app // Should disconnect, show error, and retry when re-advertised - Logger.transport.error("🛜 [BLETransport] Disconnected by peripheral.") + Logger.transport.error("🛜 [BLETransport] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)") shouldReconnect = true - case .uuidNotAllowed: // 8 - Logger.transport.error("🛜 [BLETransport] Disconnected due to UUID not allowed.") - case .alreadyAdvertising: // 9 - Logger.transport.error("🛜 [BLETransport] Disconnected because already advertising.") - case .connectionFailed: // 10 - Logger.transport.error("🛜 [BLETransport] Disconnected due to connection failure.") - case .connectionLimitReached: // 11 - Logger.transport.error("🛜 [BLETransport] Disconnected due to connection limit reached.") - case .unknownDevice, .unkownDevice: // 12 - Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown device.") - case .operationNotSupported: // 13 - Logger.transport.error("🛜 [BLETransport] Disconnected due to operation not supported.") - case .peerRemovedPairingInformation: // 14 - // Should disconnect and not retry - Logger.transport.error("🛜 [BLETransport] Disconnected because peer removed pairing information.") - case .encryptionTimedOut: // 15 - Logger.transport.error("🛜 [BLETransport] Disconnected due to encryption timeout.") - case .tooManyLEPairedDevices: // 16 - Logger.transport.error("🛜 [BLETransport] Disconnected due to too many LE paired devices.") - - // leGatt cases are watchOS only - case .leGattExceededBackgroundNotificationLimit: // 17 - Logger.transport.error("🛜 [BLETransport] Disconnected due to exceeding LE GATT background notification limit.") - case .leGattNearBackgroundNotificationLimit: // 18 - Logger.transport.error("🛜 [BLETransport] Disconnected due to nearing LE GATT background notification limit.") - - @unknown default: - Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown future error code: \(cbError.code.rawValue)") + default: + // Fallback for other CBError codes + Logger.transport.error("🛜 [BLETransport] Disconnected with CBError code: \(cbError.code.rawValue) - \(cbError.localizedDescription)") } case let otherError: Logger.transport.error("🛜 [BLETransport] Disconnected with non-CBError: \(otherError.localizedDescription)") diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index 307df86a..c0ee03c0 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -86,7 +86,7 @@ Intent ITSAppUsesNonExemptEncryption - + LSApplicationCategoryType public.app-category.utilities LSHasLocalizedDisplayName @@ -139,6 +139,8 @@ armv7 + UIRequiresPersistentWiFi + UIStatusBarStyle UISupportedInterfaceOrientations diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index b4aaea67..306810b0 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -282,4 +282,3 @@ struct ChannelMessageList: View { } } } - diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift index 05f65d82..5ca1c019 100644 --- a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -228,6 +228,11 @@ struct DeviceOnboarding: View { title: "Background Connections".localized, subtitle: "Background network connections are not supported and may disconnect when you leave the app.".localized ) + makeRow( + icon: "arrow.trianglehead.2.clockwise", + title: "Minimum Firmware Version".localized, + subtitle: "For the best connection experience, minimum firmware version 2.7.4 is required.".localized + ) } .padding() } @@ -264,9 +269,9 @@ struct DeviceOnboarding: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) makeRow( - icon: "network", - title: "Network-based Nodes".localized, - subtitle: "The Meshtastic App can connect to and manage network-enabled nodes.".localized + icon: "custom.bluetooth", + title: "Bluetooth Connected Nodes".localized, + subtitle: "The most reliable messaging experience is with Bluetooth Low Energy connected nodes.".localized ) makeRow( icon: "person.and.background.dotted", @@ -320,14 +325,25 @@ struct DeviceOnboarding: View { subtitle: String ) -> some View { HStack(alignment: .center) { - Image(systemName: icon) - .resizable() - .symbolRenderingMode(.multicolor) - .font(.subheadline) - .aspectRatio(contentMode: .fit) - .padding(.horizontal) - .padding(.vertical, 8) - .frame(width: 72, height: 60) + if icon.starts(with: "custom.") { + Image(icon) + .resizable() + .symbolRenderingMode(.multicolor) + .font(.subheadline) + .aspectRatio(contentMode: .fit) + .padding(.horizontal) + .padding(.vertical, 8) + .frame(width: 72, height: 60) + } else { + Image(systemName: icon) + .resizable() + .symbolRenderingMode(.multicolor) + .font(.subheadline) + .aspectRatio(contentMode: .fit) + .padding(.horizontal) + .padding(.vertical, 8) + .frame(width: 72, height: 60) + } VStack(alignment: .leading) { Text(title) .font(.subheadline.weight(.semibold))