diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift index a8862494..4418c995 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+FromRadio.swift @@ -218,7 +218,18 @@ extension AccessoryManager { do { let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) if fetchedMyInfo.count == 1 { + let channelsToDelete = fetchedMyInfo[0].channels + for channel in channelsToDelete { + context.delete(channel) + } fetchedMyInfo[0].channels.removeAll() + + // Clean orphaned channels from older app versions where channels were + // detached but not deleted, which can create duplicate rows in queries. + let allChannels = try context.fetch(FetchDescriptor()) + for channel in allChannels where channel.myInfoChannel == nil { + context.delete(channel) + } do { try context.save() } catch { diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index 01f0fba9..28bbc794 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -22,8 +22,19 @@ struct ChannelList: View { var restrictedChannels = ["gpio", "mqtt", "serial", "admin"] - @Query(sort: \ChannelEntity.index) - private var channels: [ChannelEntity] + private var visibleChannels: [ChannelEntity] { + guard let myInfo = node?.myInfo else { return [] } + + var channelsByIndex: [Int32: ChannelEntity] = [:] + for channel in myInfo.channels { + channelsByIndex[channel.index] = channel + } + + return channelsByIndex + .values + .filter { !restrictedChannels.contains($0.name?.lowercased() ?? "") } + .sorted { $0.index < $1.index } + } @ViewBuilder private func makeChannelRow( @@ -170,10 +181,8 @@ struct ChannelList: View { // Display Contacts for the rest of the non admin channels if let node, let myInfo = node.myInfo { List(selection: $channelSelection) { - ForEach(channels) { (channel: ChannelEntity) in - if !restrictedChannels.contains(channel.name?.lowercased() ?? "") { - makeChannelListItem(node: node, myInfo: myInfo, channel: channel) - } + ForEach(visibleChannels) { (channel: ChannelEntity) in + makeChannelListItem(node: node, myInfo: myInfo, channel: channel) } } .olderThanOS26 { $0.padding([.top, .bottom]) } diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift index f7640fcf..1512d0fd 100644 --- a/Meshtastic/Views/Settings/Channels.swift +++ b/Meshtastic/Views/Settings/Channels.swift @@ -53,6 +53,33 @@ struct Channels: View { @Query(sort: \NodeInfoEntity.lastHeard, order: .reverse) var nodes: [NodeInfoEntity] + private var displayChannels: [ChannelEntity] { + guard let channels = node.myInfo?.channels else { return [] } + var byIndex: [Int32: ChannelEntity] = [:] + for channel in channels { + byIndex[channel.index] = channel + } + return byIndex.values.sorted { $0.index < $1.index } + } + + private func normalizeDuplicateChannelsIfNeeded() { + guard let channels = node.myInfo?.channels else { return } + var uniqueChannels: [Int32: ChannelEntity] = [:] + for channel in channels { + uniqueChannels[channel.index] = channel + } + let deduped = uniqueChannels.values.sorted { $0.index < $1.index } + guard deduped.count != channels.count else { return } + node.myInfo?.channels = deduped + do { + try context.save() + Logger.data.info("💾 Normalized duplicate channels for node \(self.node.num, privacy: .public)") + } catch { + context.rollback() + Logger.data.error("Failed normalizing duplicate channels: \(error.localizedDescription, privacy: .public)") + } + } + var body: some View { VStack { @@ -61,7 +88,7 @@ struct Channels: View { .tipBackground(colorScheme == .dark ? Color(.systemBackground) : Color(.secondarySystemBackground)) .listRowSeparator(.hidden) if node.myInfo != nil { - ForEach(node.myInfo?.channels ?? [], id: \.self) { (channel: ChannelEntity) in + ForEach(displayChannels, id: \.self) { (channel: ChannelEntity) in Button(action: { channelIndex = channel.index channelRole = Int(channel.role) @@ -175,12 +202,17 @@ struct Channels: View { guard var channels = node.myInfo?.channels else { return } - if let idx = channels.firstIndex(where: { $0.psk == selectedChannel?.psk && $0.name == selectedChannel?.name }) { + if let idx = channels.firstIndex(where: { $0.index == selectedChannel?.index }) { channels[idx] = selectedChannel! } else { channels.append(selectedChannel!) } - node.myInfo?.channels = channels + + var uniqueChannels: [Int32: ChannelEntity] = [:] + for channel in channels { + uniqueChannels[channel.index] = channel + } + node.myInfo?.channels = uniqueChannels.values.sorted { $0.index < $1.index } if channel.role != Channel.Role.disabled { do { try context.save() @@ -340,6 +372,9 @@ struct Channels: View { } .padding(.bottom, 5) .navigationTitle("Channels") + .onAppear { + normalizeDuplicateChannelsIfNeeded() + } .navigationBarItems(trailing: ZStack { ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")