Keep list of previous manual connections (#1484)

* Keep list of previous manual connections

* More descriptive manual connection rows

* Merge fixes and new way to show IP on Connect view

---------

Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
This commit is contained in:
jake-b 2025-10-28 09:18:17 -04:00 committed by GitHub
parent 0c3f1bd2d6
commit 3f27e3b925
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 372 additions and 114 deletions

View file

@ -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" : {

View file

@ -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 = "<group>"; };
2346A7182E2FB9A300CB9239 /* SerialConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConnection.swift; sourceTree = "<group>"; };
2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTransport.swift; sourceTree = "<group>"; };
2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualConnectionList.swift; sourceTree = "<group>"; };
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = "<group>"; };
@ -832,6 +834,7 @@
23D316922E5618D2002FA4FB /* AsyncGate.swift */,
23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */,
23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */,
2349A0492EAE4DA30060A581 /* ManualConnectionList.swift */,
);
path = Helpers;
sourceTree = "<group>";
@ -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 */,

View file

@ -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

View file

@ -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)
}
}
}
}

View file

@ -302,9 +302,9 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
Logger.transport.error("updateDevice<T> 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()

View file

@ -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<T>(deviceId: UUID, key: WritableKeyPath<Device, T>, 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
}
}

View file

@ -31,7 +31,7 @@ enum ConnectionEvent {
case disconnected(shouldReconnect: Bool)
}
enum ConnectionState: Equatable {
enum ConnectionState: Equatable, Codable {
case disconnected
case connecting
case connected

View file

@ -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))"
}
}
}

View file

@ -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))

View file

@ -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")
}

View file

@ -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")
}
}

View file

@ -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 (065535)
return port >= 0 && port <= 65535
}
}
extension NetService {
var ipv4Address: String? {
for addressData in addresses ?? [] {

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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)
}
}
}
}
}