AI guided fix for channels list issues

This commit is contained in:
Jake-B 2026-04-08 20:07:21 -04:00
parent 24a7270e50
commit 3ac9cd47e7
7 changed files with 442 additions and 491 deletions

View file

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

View file

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

View file

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

View file

@ -22,11 +22,26 @@ struct ChannelList: View {
var restrictedChannels = ["gpio", "mqtt", "serial", "admin"]
@FetchRequest(
@FetchRequest private var channels: FetchedResults<ChannelEntity>
init(node: Binding<NodeInfoEntity?>, channelSelection: Binding<ChannelEntity?>) {
_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<ChannelEntity>
)
}
@ViewBuilder
private func makeChannelRow(

View file

@ -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<ChannelEntity>
var nodes: FetchedResults<NodeInfoEntity>
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 {

View file

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

View file

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