From 3ac9cd47e724aa34396575fb6aa8ecdfa2cd836f Mon Sep 17 00:00:00 2001 From: Jake-B Date: Wed, 8 Apr 2026 20:07:21 -0400 Subject: [PATCH] AI guided fix for channels list issues --- Localizable.xcstrings | 243 ++++++------- .../CoreData/ChannelEntityExtension.swift | 2 + Meshtastic/Helpers/MeshPackets.swift | 97 ++--- Meshtastic/Views/Messages/ChannelList.swift | 21 +- Meshtastic/Views/Settings/Channels.swift | 332 +++++++----------- .../Views/Settings/Channels/ChannelForm.swift | 234 ++++++------ Meshtastic/Views/Settings/Settings.swift | 4 +- 7 files changed, 442 insertions(+), 491 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 30e6245e..defc6403 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -2,7 +2,6 @@ "sourceLanguage" : "en", "strings" : { "" : { - "shouldTranslate" : false, "localizations" : { "da" : { "stringUnit" : { @@ -10,7 +9,8 @@ "value" : "" } } - } + }, + "shouldTranslate" : false }, "\t%@" : { "localizations" : { @@ -225,95 +225,83 @@ }, "shouldTranslate" : false }, - " : %@" : { + ": %@" : { "localizations" : { - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : " : %@" - } - }, "es" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "sr" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } }, "zh-Hant-TW" : { "stringUnit" : { "state" : "translated", - "value" : " : %@" + "value" : ": %@" } } }, "shouldTranslate" : false }, - " : %d" : { + ": %d" : { "localizations" : { - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : " : %d" - } - }, "es" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "sr" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } }, "zh-Hant-TW" : { "stringUnit" : { "state" : "translated", - "value" : " : %d" + "value" : ": %d" } } }, @@ -3018,7 +3006,9 @@ } } }, - "A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients." : {}, + "A default self-signed certificate is included for localhost connections. Import a custom .p12 if needed. Client CA (.pem) validates connecting TAK clients." : { + + }, "A green lock means the channel is securely encrypted with either a 128 or 256 bit AES key." : { "localizations" : { "es" : { @@ -3863,7 +3853,9 @@ } } }, - "Add CA" : {}, + "Add CA" : { + + }, "Add Channel" : { "localizations" : { "da" : { @@ -11484,8 +11476,12 @@ } } }, - "Client CA Certificate" : {}, - "Client Configuration" : {}, + "Client CA Certificate" : { + + }, + "Client Configuration" : { + + }, "Client Hidden" : { "extractionState" : "stale", "localizations" : { @@ -12186,7 +12182,9 @@ } } }, - "Configuration" : {}, + "Configuration" : { + + }, "Configuration for: %@" : { "localizations" : { "da" : { @@ -14570,7 +14568,9 @@ } } }, - "Delete All" : {}, + "Delete All" : { + + }, "Delete all config, keys and BLE bonds? " : { "localizations" : { "es" : { @@ -18174,7 +18174,9 @@ } } }, - "Download TAK Server Data Package" : {}, + "Download TAK Server Data Package" : { + + }, "Drag & Drop Firmware Update" : { "localizations" : { "da" : { @@ -18961,7 +18963,9 @@ } } }, - "Enable TAK Server" : {}, + "Enable TAK Server" : { + + }, "Enable this device as a Store and Forward server. Requires an ESP32 device with PSRAM." : { "localizations" : { "da" : { @@ -19728,8 +19732,12 @@ } } }, - "Enter P12 Password" : {}, - "Enter the password for the PKCS#12 file" : {}, + "Enter P12 Password" : { + + }, + "Enter the password for the PKCS#12 file" : { + + }, "environment" : { "extractionState" : "stale", "localizations" : { @@ -23771,7 +23779,9 @@ } } }, - "Generate a data package (.zip) to configure TAK clients to connect to this server." : {}, + "Generate a data package (.zip) to configure TAK clients to connect to this server." : { + + }, "Generate a new private key to replace the one currently in use. The public key will automatically be regenerated from your private key." : { "localizations" : { "es" : { @@ -27266,10 +27276,18 @@ } } }, - "Import" : {}, - "Import .pem" : {}, - "Import Custom .p12" : {}, - "Import Error" : {}, + "Import" : { + + }, + "Import .pem" : { + + }, + "Import Custom .p12" : { + + }, + "Import Error" : { + + }, "Import Route" : { "localizations" : { "da" : { @@ -32997,7 +33015,9 @@ } } }, - "mTLS" : {}, + "mTLS" : { + + }, "Multiplier" : { "localizations" : { "da" : { @@ -39159,7 +39179,9 @@ } } }, - "Port" : {}, + "Port" : { + + }, "Position" : { "localizations" : { "da" : { @@ -42816,7 +42838,9 @@ } } }, - "Reload Bundled Certificates" : {}, + "Reload Bundled Certificates" : { + + }, "Remote administration for: %@" : { "localizations" : { "da" : { @@ -43623,7 +43647,9 @@ } } }, - "Reset to Default" : {}, + "Reset to Default" : { + + }, "Restart" : { "localizations" : { "da" : { @@ -43676,7 +43702,9 @@ } } }, - "Restart Server" : {}, + "Restart Server" : { + + }, "Restart to the node you are connected to" : { "localizations" : { "da" : { @@ -46448,8 +46476,6 @@ } } }, - "Secure mTLS connection on port 8089. Both server and client certificates are required." : {}, - "Secure mTLS connection on port 8089. Both server and client certificates are required. TAK Channel Index selects the channel index where TAK messages will be sent." : { "comment" : "A footer for the TAK Server configuration section.", "isCommentAutoGenerated" : true @@ -49143,7 +49169,9 @@ } } }, - "Server Certificate" : {}, + "Server Certificate" : { + + }, "Server Option" : { "localizations" : { "da" : { @@ -49190,7 +49218,9 @@ } } }, - "Server Status" : {}, + "Server Status" : { + + }, "Set" : { "localizations" : { "da" : { @@ -49237,6 +49267,10 @@ } } }, + "Set a channel name" : { + "comment" : "A label describing the action to set a channel name.", + "isCommentAutoGenerated" : true + }, "Set LoRa Region" : { "localizations" : { "da" : { @@ -49856,6 +49890,10 @@ } } }, + "Share with TAK Buddies" : { + "comment" : "A button that allows a user to share the QR code of their TAK-configured channel with other TAK users.", + "isCommentAutoGenerated" : true + }, "Share your location in real-time and keep your group coordinated with integrated GPS features." : { "localizations" : { "de" : { @@ -52018,7 +52056,9 @@ } } }, - "Status" : {}, + "Status" : { + + }, "Stay Connected Anywhere" : { "localizations" : { "de" : { @@ -52656,9 +52696,18 @@ } } } + }, + "TAK Cannot Be Used on Public Channel" : { + "comment" : "A warning title that appears when the TAK Server is configured to use the public channel.", + "isCommentAutoGenerated" : true + }, + "TAK Channel Index" : { + "comment" : "A label for the index of the TAK channel.", + "isCommentAutoGenerated" : true + }, + "TAK Server" : { }, - "TAK Server" : {}, "TAK Tracker" : { "extractionState" : "stale", "localizations" : { @@ -55989,7 +56038,9 @@ } } }, - "TLS Certificates" : {}, + "TLS Certificates" : { + + }, "TLS Enabled" : { "localizations" : { "da" : { @@ -62889,88 +62940,6 @@ } } } - }, - ": %@" : { - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %@" - } - } - }, - "shouldTranslate" : false - }, - ": %d" : { - "localizations" : { - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - }, - "zh-Hant-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : ": %d" - } - } - }, - "shouldTranslate" : false } }, "version" : "1.1" diff --git a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift index 8fce761c..731df4a9 100644 --- a/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/ChannelEntityExtension.swift @@ -54,6 +54,8 @@ extension ChannelEntity { channel.settings.name = self.name ?? "" channel.settings.psk = self.psk ?? Data() channel.role = Channel.Role(rawValue: Int(self.role)) ?? Channel.Role.secondary + channel.settings.uplinkEnabled = self.uplinkEnabled + channel.settings.downlinkEnabled = self.downlinkEnabled channel.settings.moduleSettings.positionPrecision = UInt32(self.positionPrecision) channel.settings.moduleSettings.isMuted = self.mute return channel diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 54e1661f..fb51b54f 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -179,53 +179,58 @@ actor MeshPackets { } nonisolated private func channelPacket (channel: Channel, fromNum: Int64, context: NSManagedObjectContext) { - if channel.isInitialized && channel.hasSettings && channel.role != Channel.Role.disabled { - let logString = String.localizedStringWithFormat("mesh.log.channel.received %d %@".localized, channel.index, String(fromNum)) - Logger.mesh.info("πŸŽ›οΈ \(logString, privacy: .public)") - - let fetchedMyInfoRequest = MyInfoEntity.fetchRequest() - fetchedMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", fromNum) - - do { - let fetchedMyInfo = try context.fetch(fetchedMyInfoRequest) - if fetchedMyInfo.count == 1 { - let newChannel = ChannelEntity(context: context) - newChannel.id = Int32(channel.index) - newChannel.index = Int32(channel.index) - newChannel.uplinkEnabled = channel.settings.uplinkEnabled - newChannel.downlinkEnabled = channel.settings.downlinkEnabled - newChannel.name = channel.settings.name - newChannel.role = Int32(channel.role.rawValue) - newChannel.psk = channel.settings.psk - if channel.settings.hasModuleSettings { - newChannel.positionPrecision = Int32(truncatingIfNeeded: channel.settings.moduleSettings.positionPrecision) - newChannel.mute = channel.settings.moduleSettings.isMuted - } - guard let mutableChannels = fetchedMyInfo[0].channels!.mutableCopy() as? NSMutableOrderedSet else { - return - } - if let oldChannel = mutableChannels.first(where: {($0 as AnyObject).index == newChannel.index }) as? ChannelEntity { - let index = mutableChannels.index(of: oldChannel as Any) - mutableChannels.replaceObject(at: index, with: newChannel) - } else { - mutableChannels.add(newChannel) - } - fetchedMyInfo[0].channels = mutableChannels.copy() as? NSOrderedSet - context.refresh(newChannel, mergeChanges: true) - do { - try context.save() - } catch { - Logger.data.error("πŸ’₯ Failed to save channel: \(error.localizedDescription, privacy: .public)") - } - Logger.data.info("πŸ’Ύ Updated MyInfo channel \(channel.index, privacy: .public) from Channel App Packet For: \(fetchedMyInfo[0].myNodeNum, privacy: .public)") - } else if channel.role.rawValue > 0 { - Logger.data.error("πŸ’₯Trying to save a channel to a MyInfo that does not exist: \(fromNum.toHex(), privacy: .public)") - } - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("πŸ’₯ Error Saving MyInfo Channel from ADMIN_APP \(nsError, privacy: .public)") + guard channel.isInitialized && channel.hasSettings && channel.role != Channel.Role.disabled else { return } + let logString = String.localizedStringWithFormat("mesh.log.channel.received %d %@".localized, channel.index, String(fromNum)) + Logger.mesh.info("πŸŽ›οΈ \(logString, privacy: .public)") + + let fetchedMyInfoRequest = MyInfoEntity.fetchRequest() + fetchedMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", fromNum) + fetchedMyInfoRequest.fetchLimit = 1 + + do { + let fetchedMyInfo = try context.fetch(fetchedMyInfoRequest) + guard let myInfo = fetchedMyInfo.first else { + Logger.data.error("πŸ’₯ Trying to save a channel to a MyInfo that does not exist: \(fromNum.toHex(), privacy: .public)") + return } + + // Fetch by index alone (the uniqueness key) so we always find an existing entity + // regardless of whether myInfoChannel was previously corrupted to nil. + // Never insert a new entity that would conflict with an existing one β€” the resulting + // NSManagedObjectContextDidSave notification would carry the pre-resolution nil value + // for myInfoChannel, making the channel vanish from any @FetchRequest predicated on it. + let channelFetch = ChannelEntity.fetchRequest() + channelFetch.predicate = NSPredicate(format: "index == %d", Int(channel.index)) + channelFetch.fetchLimit = 1 + + let channelEntity: ChannelEntity + if let existing = try context.fetch(channelFetch).first { + channelEntity = existing + } else { + channelEntity = ChannelEntity(context: context) + channelEntity.id = Int32(channel.index) + channelEntity.index = Int32(channel.index) + } + + // Always (re-)establish the relationship β€” a no-op if already correct, + // but repairs channels whose myInfoChannel was nullified by the old code. + channelEntity.myInfoChannel = myInfo + channelEntity.uplinkEnabled = channel.settings.uplinkEnabled + channelEntity.downlinkEnabled = channel.settings.downlinkEnabled + channelEntity.name = channel.settings.name + channelEntity.role = Int32(channel.role.rawValue) + channelEntity.psk = channel.settings.psk + if channel.settings.hasModuleSettings { + channelEntity.positionPrecision = Int32(truncatingIfNeeded: channel.settings.moduleSettings.positionPrecision) + channelEntity.mute = channel.settings.moduleSettings.isMuted + } + + try context.save() + Logger.data.info("πŸ’Ύ Updated MyInfo channel \(channel.index, privacy: .public) from Channel App Packet For: \(myInfo.myNodeNum, privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("πŸ’₯ Error Saving MyInfo Channel from ADMIN_APP \(nsError, privacy: .public)") } } diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index bf3c2752..826618be 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -22,11 +22,26 @@ struct ChannelList: View { var restrictedChannels = ["gpio", "mqtt", "serial", "admin"] - @FetchRequest( + @FetchRequest private var channels: FetchedResults + + init(node: Binding, channelSelection: Binding) { + _node = node + _channelSelection = channelSelection + let predicate: NSPredicate + if let nodeNum = node.wrappedValue?.num { + predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "myInfoChannel.myNodeNum == %lld", nodeNum), + NSPredicate(format: "role > 0") + ]) + } else { + predicate = NSPredicate(value: false) + } + _channels = FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \ChannelEntity.index, ascending: true)], - predicate: nil, + predicate: predicate, animation: .default - ) private var channels: FetchedResults + ) + } @ViewBuilder private func makeChannelRow( diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift index f5c89071..a1694689 100644 --- a/Meshtastic/Views/Settings/Channels.swift +++ b/Meshtastic/Views/Settings/Channels.swift @@ -28,35 +28,23 @@ struct Channels: View { @Environment(\.sizeCategory) var sizeCategory @Environment(\.colorScheme) private var colorScheme - var node: NodeInfoEntity? + var node: NodeInfoEntity - @State var hasChanges = false - @State var hasValidKey = true - @State private var isPresentingSaveConfirm: Bool = false - @State var channelIndex: Int32 = 0 - @State var channelName = "" - @State var channelKeySize = 16 - @State var channelKey = "AQ==" - @State var channelRole = 0 - @State var uplink = false - @State var downlink = false - @State var positionPrecision = 32.0 - @State var preciseLocation = true - @State var positionsEnabled = true - @State var supportedVersion = true - @State var selectedChannel: ChannelEntity? - - /// Minimum Version for granular position configuration - @State var minimumVersion = "2.2.24" + @State private var isEditingChannel = false + @State private var selectedChannel: ChannelEntity? + @State private var editContext: NSManagedObjectContext? @State private var showingHelp = false - @FetchRequest( - sortDescriptors: [NSSortDescriptor(key: "favorite", ascending: false), - NSSortDescriptor(key: "lastHeard", ascending: false), - NSSortDescriptor(key: "user.longName", ascending: true)], - animation: .default) + @FetchRequest private var channels: FetchedResults - var nodes: FetchedResults + init(node: NodeInfoEntity) { + self.node = node + _channels = FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \ChannelEntity.index, ascending: true)], + predicate: NSPredicate(format: "myInfoChannel.myNodeNum == %lld", node.num), + animation: .default + ) + } var body: some View { @@ -65,79 +53,24 @@ struct Channels: View { TipView(CreateChannelsTip(), arrowEdge: .bottom) .tipBackground(colorScheme == .dark ? Color(.systemBackground) : Color(.secondarySystemBackground)) .listRowSeparator(.hidden) - if node != nil && node?.myInfo != nil { - ForEach(node?.myInfo?.channels?.array as? [ChannelEntity] ?? [], id: \.self) { (channel: ChannelEntity) in - Button(action: { - channelIndex = channel.index - channelRole = Int(channel.role) - channelKey = channel.psk?.base64EncodedString() ?? "" - if channelKey.count == 0 { - channelKeySize = 0 - } else if channelKey == "AQ==" { - channelKeySize = -1 - } else if channelKey.count == 4 { - channelKeySize = 1 - } else if channelKey.count == 24 { - channelKeySize = 16 - } else if channelKey.count == 32 { - channelKeySize = 24 - } else if channelKey.count == 44 { - channelKeySize = 32 - } - channelName = channel.name ?? "" - uplink = channel.uplinkEnabled - downlink = channel.downlinkEnabled - positionPrecision = Double(channel.positionPrecision) - if !supportedVersion && channelRole == 1 { - positionPrecision = 32 - preciseLocation = true - positionsEnabled = true - if channelKey == "AQ==" { - positionPrecision = 14 - preciseLocation = false - } - } else if !supportedVersion && channelRole == 2 { - positionPrecision = 0 - preciseLocation = false - positionsEnabled = false - } else { - if channelKey == "AQ==" { - preciseLocation = false - if (positionPrecision > 0 && positionPrecision < 11) || positionPrecision > 14 { - positionPrecision = 14 - } - } else if positionPrecision == 32 { - preciseLocation = true - positionsEnabled = true - } else { - preciseLocation = false - } - if positionPrecision == 0 { - positionsEnabled = false - } else { - positionsEnabled = true - } - } - hasChanges = false - selectedChannel = channel - }) { - VStack(alignment: .leading) { - HStack { - CircleText(text: String(channel.index), color: .accentColor, circleSize: 45) - .padding(.trailing, 5) - .brightness(0.1) - VStack { - HStack { - ChannelLock(channel: channel) - if channel.name?.isEmpty ?? false { - if channel.role == 1 { - Text(String("PrimaryChannel").camelCaseToWords()).font(.headline) - } else { - Text(String("Channel \(channel.index)").camelCaseToWords()).font(.headline) - } + ForEach(channels) { channel in + Button(action: { beginEditing(channel: channel) }) { + VStack(alignment: .leading) { + HStack { + CircleText(text: String(channel.index), color: .accentColor, circleSize: 45) + .padding(.trailing, 5) + .brightness(0.1) + VStack { + HStack { + ChannelLock(channel: channel) + if channel.name?.isEmpty ?? true { + if channel.role == 1 { + Text(String("PrimaryChannel").camelCaseToWords()).font(.headline) } else { - Text(String(channel.name ?? "Channel \(channel.index)").camelCaseToWords()).font(.headline) + Text(String("Channel \(channel.index)").camelCaseToWords()).font(.headline) } + } else { + Text(String(channel.name ?? "Channel \(channel.index)").camelCaseToWords()).font(.headline) } } } @@ -146,89 +79,24 @@ struct Channels: View { } } } - .sheet(item: $selectedChannel) { _ in + .sheet(isPresented: $isEditingChannel, onDismiss: cancelEditing) { #if targetEnvironment(macCatalyst) Text("Channel") .font(.largeTitle) .padding() #endif - ChannelForm(channelIndex: $channelIndex, channelName: $channelName, channelKeySize: $channelKeySize, channelKey: $channelKey, channelRole: $channelRole, uplink: $uplink, downlink: $downlink, positionPrecision: $positionPrecision, preciseLocation: $preciseLocation, positionsEnabled: $positionsEnabled, hasChanges: $hasChanges, hasValidKey: $hasValidKey, supportedVersion: $supportedVersion) - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - .onFirstAppear { - supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion) + if let channel = selectedChannel { + ChannelForm(channel: channel) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) } HStack { Button { - var channel = Channel() - channel.index = channelIndex - channel.role = ChannelRoles(rawValue: channelRole)?.protoEnumValue() ?? .secondary - channel.index = channelIndex - channel.settings.name = channelName - channel.settings.psk = Data(base64Encoded: channelKey) ?? Data() - channel.settings.uplinkEnabled = uplink - channel.settings.downlinkEnabled = downlink - channel.settings.moduleSettings.positionPrecision = UInt32(positionPrecision) - selectedChannel!.role = Int32(channelRole) - selectedChannel!.index = channelIndex - selectedChannel!.name = channelName - selectedChannel!.psk = Data(base64Encoded: channelKey) ?? Data() - selectedChannel!.uplinkEnabled = uplink - selectedChannel!.downlinkEnabled = downlink - selectedChannel!.positionPrecision = Int32(positionPrecision) - - guard let mutableChannels = node?.myInfo?.channels?.mutableCopy() as? NSMutableOrderedSet else { - return - } - if mutableChannels.contains(selectedChannel as Any) { - let replaceChannel = mutableChannels.first(where: { selectedChannel?.psk == ($0 as AnyObject).psk && selectedChannel?.name == ($0 as AnyObject).name}) - mutableChannels.replaceObject(at: mutableChannels.index(of: replaceChannel as Any), with: selectedChannel as Any) - } else { - mutableChannels.add(selectedChannel as Any) - } - node?.myInfo?.channels = mutableChannels.copy() as? NSOrderedSet - context.refresh(selectedChannel!, mergeChanges: true) - if channel.role != Channel.Role.disabled { - do { - try context.save() - Logger.data.info("πŸ’Ύ Saved Channel: \(channel.settings.name, privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Unresolved Core Data error in the channel editor. Error: \(nsError, privacy: .public)") - } - } else { - let objects = selectedChannel?.allPrivateMessages ?? [] - for object in objects { - context.delete(object) - } - for node in nodes where node.channel == channel.index { - context.delete(node) - } - context.delete(selectedChannel!) - do { - try context.save() - Logger.data.info("πŸ’Ύ Deleted Channel: \(channel.settings.name, privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Unresolved Core Data error in the channel editor. Error: \(nsError, privacy: .public)") - } - } - Task { - _ = try await accessoryManager.saveChannel(channel: channel, fromUser: node!.user!, toUser: node!.user!) - Task { @MainActor in - selectedChannel = nil - channelName = "" - channelRole = 2 - hasChanges = false - } - accessoryManager.mqttManager.connectFromConfigSettings(node: node!) - } + saveChannel() } label: { Label("Save", systemImage: "square.and.arrow.down") } - .disabled(!accessoryManager.isConnected)// || !hasChanges)// !hasValidKey) + .disabled(!accessoryManager.isConnected) .buttonStyle(.bordered) .buttonBorderShape(.capsule) .controlSize(.large) @@ -246,37 +114,9 @@ struct Channels: View { #endif } } - if node?.myInfo?.channels?.array.count ?? 0 < 8 && node != nil { - + if channels.count < 8 { Button { - let channelIndexes = node?.myInfo?.channels?.compactMap({(ch) -> Int in - return (ch as AnyObject).index - }) - let firstChannelIndex = firstMissingChannelIndex(channelIndexes ?? []) - channelKeySize = 16 - let key = generateChannelKey(size: channelKeySize) - channelName = "" - channelIndex = Int32(firstChannelIndex) - channelRole = 2 - channelKey = key - positionsEnabled = false - preciseLocation = false - positionPrecision = 0 - uplink = false - downlink = false - - let newChannel = ChannelEntity(context: context) - newChannel.id = channelIndex - newChannel.index = channelIndex - newChannel.uplinkEnabled = uplink - newChannel.downlinkEnabled = downlink - newChannel.name = channelName - newChannel.role = Int32(channelRole) - newChannel.psk = Data(base64Encoded: channelKey) ?? Data() - newChannel.positionPrecision = Int32(positionPrecision) - selectedChannel = newChannel - hasChanges = true - + addChannel() } label: { Label("Add Channel", systemImage: "plus.square") } @@ -315,6 +155,102 @@ struct Channels: View { ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") }) } + + // MARK: - Editing helpers + + private func beginEditing(channel: ChannelEntity) { + let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + childContext.parent = context + guard let channelInChild = childContext.object(with: channel.objectID) as? ChannelEntity else { return } + editContext = childContext + selectedChannel = channelInChild + isEditingChannel = true + } + + private func addChannel() { + let channelIndexes = channels.map { Int($0.index) } + let nextIndex = firstMissingChannelIndex(channelIndexes) + let key = generateChannelKey(size: 16) + + let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) + childContext.parent = context + + let newChannel = ChannelEntity(context: childContext) + newChannel.id = Int32(nextIndex) + newChannel.index = Int32(nextIndex) + newChannel.role = 2 // Secondary + newChannel.name = "" + newChannel.psk = Data(base64Encoded: key) + newChannel.uplinkEnabled = false + newChannel.downlinkEnabled = false + newChannel.positionPrecision = 0 + + if let myInfo = node.myInfo, + let myInfoInChild = childContext.object(with: myInfo.objectID) as? MyInfoEntity { + newChannel.myInfoChannel = myInfoInChild + } + + editContext = childContext + selectedChannel = newChannel + isEditingChannel = true + } + + private func saveChannel() { + guard let editCtx = editContext, let channel = selectedChannel else { return } + + let isNew = channel.objectID.isTemporaryID + let channelIndex = channel.index + let proto = channel.protoBuf + + if channel.role == 0 { // Disabled = delete existing channel + if !isNew, let parentChannel = context.object(with: channel.objectID) as? ChannelEntity { + for message in parentChannel.allPrivateMessages { + context.delete(message) + } + let nodesFetch = NodeInfoEntity.fetchRequest() + nodesFetch.predicate = NSPredicate(format: "channel == %d", channelIndex) + let orphans = (try? context.fetch(nodesFetch)) ?? [] + for orphan in orphans { + context.delete(orphan) + } + context.delete(parentChannel) + do { + try context.save() + Logger.data.info("πŸ’Ύ Deleted Channel \(channelIndex)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Unresolved CoreData error deleting channel. Error: \(nsError, privacy: .public)") + } + } + isEditingChannel = false + return + } + + do { + try editCtx.save() + try context.save() + Logger.data.info("πŸ’Ύ Saved Channel: \(proto.settings.name, privacy: .public)") + } catch { + editCtx.rollback() + let nsError = error as NSError + Logger.data.error("Unresolved CoreData error saving channel. Error: \(nsError, privacy: .public)") + return + } + + Task { + _ = try? await accessoryManager.saveChannel(channel: proto, fromUser: node.user!, toUser: node.user!) + Task { @MainActor in + isEditingChannel = false + } + accessoryManager.mqttManager.connectFromConfigSettings(node: node) + } + } + + private func cancelEditing() { + selectedChannel = nil + editContext = nil + } } func firstMissingChannelIndex(_ indexes: [Int]) -> Int { diff --git a/Meshtastic/Views/Settings/Channels/ChannelForm.swift b/Meshtastic/Views/Settings/Channels/ChannelForm.swift index 3579b938..26f0b5ad 100644 --- a/Meshtastic/Views/Settings/Channels/ChannelForm.swift +++ b/Meshtastic/Views/Settings/Channels/ChannelForm.swift @@ -10,19 +10,18 @@ import MapKit struct ChannelForm: View { - @Binding var channelIndex: Int32 - @Binding var channelName: String - @Binding var channelKeySize: Int - @Binding var channelKey: String - @Binding var channelRole: Int - @Binding var uplink: Bool - @Binding var downlink: Bool - @Binding var positionPrecision: Double - @Binding var preciseLocation: Bool - @Binding var positionsEnabled: Bool - @Binding var hasChanges: Bool - @Binding var hasValidKey: Bool - @Binding var supportedVersion: Bool + @ObservedObject var channel: ChannelEntity + @EnvironmentObject var accessoryManager: AccessoryManager + + /// UI-only state derived from channel data on appear + @State private var channelKey = "" + @State private var channelKeySize = 16 + @State private var positionsEnabled = true + @State private var preciseLocation = true + @State private var hasValidKey = true + @State private var supportedVersion = true + + private let minimumVersion = "2.2.24" var body: some View { NavigationStack { @@ -33,20 +32,18 @@ struct ChannelForm: View { Spacer() TextField( "Channel Name", - text: $channelName + text: Binding( + get: { channel.name ?? "" }, + set: { channel.name = $0 } + ) ) .disableAutocorrection(true) .keyboardType(.alphabet) .foregroundColor(Color.gray) - .onChange(of: channelName) { - channelName = channelName.replacing(" ", with: "") - var totalBytes = channelName.utf8.count - // Only mess with the value if it is too big - while totalBytes > 11 { - channelName = String(channelName.dropLast()) - totalBytes = channelName.utf8.count - } - hasChanges = true + .onChange(of: channel.name) { _, name in + var trimmed = (name ?? "").replacingOccurrences(of: " ", with: "") + while trimmed.utf8.count > 11 { trimmed = String(trimmed.dropLast()) } + if trimmed != name { channel.name = trimmed } } } HStack { @@ -62,10 +59,12 @@ struct ChannelForm: View { Button { if channelKeySize == -1 { channelKey = "AQ==" + } else if channelKeySize > 0 { + channelKey = generateChannelKey(size: channelKeySize) } else { - let key = generateChannelKey(size: channelKeySize) - channelKey = key + channelKey = "" } + channel.psk = Data(base64Encoded: channelKey) } label: { Image(systemName: "lock.rotation") .font(.title) @@ -90,27 +89,22 @@ struct ChannelForm: View { .background( RoundedRectangle(cornerRadius: 10.0) .stroke( - hasValidKey ? - Color.clear : - Color.red - , lineWidth: 2.0) - + hasValidKey ? Color.clear : Color.red, + lineWidth: 2.0) ) - .onChange(of: channelKey) { - - let tempKey = Data(base64Encoded: channelKey) ?? Data() - if tempKey.count == channelKeySize || channelKeySize == -1 { - hasValidKey = true - } else { - hasValidKey = false - } - hasChanges = true + .onChange(of: channelKey) { _, key in + let data = Data(base64Encoded: key) ?? Data() + hasValidKey = data.count == channelKeySize || channelKeySize == -1 + channel.psk = data.isEmpty ? nil : data } .disabled(channelKeySize <= 0) } HStack { - if channelRole == 1 { - Picker("Channel Role", selection: $channelRole) { + if channel.role == 1 { + Picker("Channel Role", selection: Binding( + get: { Int(channel.role) }, + set: { channel.role = Int32($0) } + )) { Text("Primary").tag(1) } .pickerStyle(.automatic) @@ -118,7 +112,10 @@ struct ChannelForm: View { } else { Text("Channel Role") Spacer() - Picker("Channel Role", selection: $channelRole) { + Picker("Channel Role", selection: Binding( + get: { Int(channel.role) }, + set: { channel.role = Int32($0) } + )) { Text("Disabled").tag(0) Text("Secondary").tag(2) } @@ -130,14 +127,17 @@ struct ChannelForm: View { Section(header: Text("Position")) { VStack(alignment: .leading) { Toggle(isOn: $positionsEnabled) { - Label(channelRole == 1 ? "Positions Enabled" : "Allow Position Requests", systemImage: positionsEnabled ? "mappin" : "mappin.slash") + Label( + channel.role == 1 ? "Positions Enabled" : "Allow Position Requests", + systemImage: positionsEnabled ? "mappin" : "mappin.slash" + ) } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) .disabled(!supportedVersion) } if positionsEnabled { - if (channelKey != "AQ==" && channelKeySize > 1) && channelRole > 0 { + if channelKey != "AQ==" && channelKeySize > 1 && channel.role > 0 { VStack(alignment: .leading) { Toggle(isOn: $preciseLocation) { Label("Precise Location", systemImage: "scope") @@ -145,108 +145,130 @@ struct ChannelForm: View { .toggleStyle(SwitchToggleStyle(tint: .accentColor)) .disabled(!supportedVersion) .listRowSeparator(.visible) - .onChange(of: preciseLocation) { _, pl in - if pl == false { - positionPrecision = 15 - } - } } } if !preciseLocation { VStack(alignment: .leading) { Label("Approximate Location", systemImage: "location.slash.circle.fill") - - Slider(value: $positionPrecision, in: 12...15, step: 1) { + Slider( + value: Binding( + get: { Double(channel.positionPrecision) }, + set: { channel.positionPrecision = Int32($0) } + ), + in: 12...15, + step: 1 + ) { } minimumValueLabel: { Image(systemName: "plus") } maximumValueLabel: { Image(systemName: "minus") } - Text(PositionPrecision(rawValue: Int(positionPrecision))?.description ?? "") + Text(PositionPrecision(rawValue: Int(channel.positionPrecision))?.description ?? "") .foregroundColor(.gray) .font(.callout) } } } } + Section(header: Text("MQTT")) { - Toggle(isOn: $uplink) { + Toggle(isOn: Binding( + get: { channel.uplinkEnabled }, + set: { channel.uplinkEnabled = $0 } + )) { Label("Uplink Enabled", systemImage: "arrowshape.up") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) .listRowSeparator(.visible) - Toggle(isOn: $downlink) { + Toggle(isOn: Binding( + get: { channel.downlinkEnabled }, + set: { channel.downlinkEnabled = $0 } + )) { Label("Downlink Enabled", systemImage: "arrowshape.down") } .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } } - .onChange(of: channelName) { - hasChanges = true - } - .onChange(of: channelKeySize) { - if channelKeySize == -1 { - channelKey = "AQ==" - } else { - let key = generateChannelKey(size: channelKeySize) - channelKey = key - } - hasChanges = true - } - .onChange(of: channelKey) { - hasChanges = true - } - .onChange(of: channelKeySize) { - if channelKeySize == -1 { - if channelRole == 0 { + .onFirstAppear { + supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion) + channelKey = channel.psk?.base64EncodedString() ?? "" + channelKeySize = keySizeFromPsk(channel.psk) + + if !supportedVersion { + if channel.role == 1 { + positionsEnabled = true + if channelKey == "AQ==" { + preciseLocation = false + channel.positionPrecision = 14 + } else { + preciseLocation = true + channel.positionPrecision = 32 + } + } else { + positionsEnabled = false preciseLocation = false + channel.positionPrecision = 0 + } + } else { + positionsEnabled = channel.positionPrecision > 0 + if channelKey == "AQ==" { + preciseLocation = false + let p = channel.positionPrecision + if p > 0 && (p < 11 || p > 14) { + channel.positionPrecision = 14 + } + } else { + preciseLocation = channel.positionPrecision == 32 } - channelKey = "AQ==" } } - .onChange(of: channelRole) { - hasChanges = true + .onChange(of: channelKeySize) { _, size in + if size == -1 { + channelKey = "AQ==" + } else if size > 0 { + channelKey = generateChannelKey(size: size) + } else { + channelKey = "" + } + channel.psk = channelKey.isEmpty ? nil : Data(base64Encoded: channelKey) } - .onChange(of: preciseLocation) { _, loc in - if loc == true { + .onChange(of: positionsEnabled) { _, enabled in + if enabled { + if channel.positionPrecision == 0 { + channel.positionPrecision = 15 + } + } else { + channel.positionPrecision = 0 + preciseLocation = false + } + } + .onChange(of: preciseLocation) { _, precise in + if precise { if channelKey == "AQ==" || channelKeySize <= 1 { preciseLocation = false } else { - positionPrecision = 32 + channel.positionPrecision = 32 } } else { - positionPrecision = 14 - } - hasChanges = true - } - .onChange(of: positionPrecision) { - hasChanges = true - } - .onChange(of: positionsEnabled) { _, pe in - if pe { - if positionPrecision == 0 { - positionPrecision = 15 + if channel.positionPrecision == 32 { + channel.positionPrecision = 14 } - } else { - positionPrecision = 0 - } - hasChanges = true - } - .onChange(of: uplink) { - hasChanges = true - } - .onChange(of: downlink) { - hasChanges = true - } - .onFirstAppear { - let tempKey = Data(base64Encoded: channelKey) ?? Data() - if tempKey.count == channelKeySize || channelKeySize == -1 { - hasValidKey = true - } else { - hasValidKey = false } } } } + + private func keySizeFromPsk(_ psk: Data?) -> Int { + let key = psk?.base64EncodedString() ?? "" + if key.isEmpty { return 0 } + if key == "AQ==" { return -1 } + switch key.count { + case 4: return 1 + case 24: return 16 + case 32: return 24 + case 44: return 32 + default: return 16 + } + } } diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 449efc6c..7c014296 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -487,7 +487,9 @@ struct Settings: View { case .lora: LoRaConfig(node: nodes.first(where: { $0.num == selectedNode })) case .channels: - Channels(node: node) + if let node { + Channels(node: node) + } case .shareQRCode: ShareChannels(node: node) case .user: