mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge remote-tracking branch 'refs/remotes/origin/2.7.6'
This commit is contained in:
commit
ebc84d3efd
24 changed files with 481 additions and 134 deletions
|
|
@ -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" : {
|
||||
|
|
@ -28470,6 +28480,7 @@
|
|||
}
|
||||
},
|
||||
"Received Ack" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -28532,8 +28543,12 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Received Ack: %@" : {
|
||||
|
||||
},
|
||||
"Recipient Ack" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -28596,6 +28611,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recipient Ack: %@" : {
|
||||
|
||||
},
|
||||
"Recording route" : {
|
||||
"localizations" : {
|
||||
|
|
@ -28779,6 +28797,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Relayed by %d %@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Relayed by %1$d %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Release Notes" : {
|
||||
"localizations" : {
|
||||
"it" : {
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
61
Meshtastic/Accessory/Helpers/ManualConnectionList.swift
Normal file
61
Meshtastic/Accessory/Helpers/ManualConnectionList.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ enum ConnectionEvent {
|
|||
case disconnected(shouldReconnect: Bool)
|
||||
}
|
||||
|
||||
enum ConnectionState: Equatable {
|
||||
enum ConnectionState: Equatable, Codable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
|
|
|
|||
|
|
@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (0–65535)
|
||||
return port >= 0 && port <= 65535
|
||||
}
|
||||
}
|
||||
|
||||
extension NetService {
|
||||
var ipv4Address: String? {
|
||||
for addressData in addresses ?? [] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,8 @@ extension ChannelEntity {
|
|||
}
|
||||
|
||||
func unreadMessages(context: NSManagedObjectContext) -> Int {
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let fetchRequest = messageFetchRequest
|
||||
fetchRequest.sortDescriptors = [] // sort is irrelvant.
|
||||
fetchRequest.sortDescriptors = [] // sort is irrelevant.
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
|
||||
|
||||
return (try? context.count(for: fetchRequest)) ?? 0
|
||||
|
|
|
|||
|
|
@ -38,4 +38,39 @@ extension MessageEntity {
|
|||
}
|
||||
return false // First message will have no timestamp
|
||||
}
|
||||
|
||||
func relayDisplay() -> String? {
|
||||
|
||||
guard self.relayNode != 0 else { return nil }
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
|
||||
let relaySuffix = Int64(self.relayNode & 0xFF)
|
||||
let request: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "(num & 0xFF) == %lld", relaySuffix)
|
||||
|
||||
do {
|
||||
let users = try context.fetch(request)
|
||||
|
||||
// If exactly one match is found, return its name
|
||||
if users.count == 1, let name = users.first?.longName, !name.isEmpty {
|
||||
return "\(name)"
|
||||
}
|
||||
|
||||
// If no exact match, find the node with the smallest hopsAway
|
||||
if let closestNode = users.min(by: { lhs, rhs in
|
||||
guard let lhsHops = lhs.userNode?.hopsAway, let rhsHops = rhs.userNode?.hopsAway else {
|
||||
return false
|
||||
}
|
||||
return lhsHops < rhsHops
|
||||
}), let name = closestNode.longName, !name.isEmpty {
|
||||
return "\(name)"
|
||||
}
|
||||
|
||||
// Fallback to hex node number if no matches
|
||||
return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF))
|
||||
|
||||
} catch {
|
||||
return String(format: "Node 0x%02X", UInt32(self.relayNode & 0xFF))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ extension MyInfoEntity {
|
|||
func unreadMessages(context: NSManagedObjectContext) -> Int {
|
||||
// Returns the count of unread *channel* messages
|
||||
let fetchRequest = messageFetchRequest
|
||||
fetchRequest.sortDescriptors = [] // sort is irrelvant.
|
||||
fetchRequest.sortDescriptors = [] // sort is irrelevant.
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
|
||||
|
||||
return (try? context.count(for: fetchRequest)) ?? 0
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ extension UserEntity {
|
|||
guard self.lastMessage != nil || skipLastMessageCheck else { return 0; }
|
||||
|
||||
let fetchRequest = messageFetchRequest
|
||||
fetchRequest.sortDescriptors = [] // sort is irrelvant.
|
||||
fetchRequest.sortDescriptors = [] // sort is irrelevant.
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [fetchRequest.predicate!, NSPredicate(format: "read == false")])
|
||||
|
||||
return (try? context.count(for: fetchRequest)) ?? 0
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -624,6 +624,7 @@ func adminResponseAck (packet: MeshPacket, context: NSManagedObjectContext) {
|
|||
fetchedMessage[0].ackError = Int32(RoutingError.none.rawValue)
|
||||
fetchedMessage[0].receivedACK = true
|
||||
fetchedMessage[0].realACK = true
|
||||
fetchedMessage[0].relayNode = Int64(packet.relayNode)
|
||||
fetchedMessage[0].ackSNR = packet.rxSnr
|
||||
if fetchedMessage[0].fromUser != nil {
|
||||
fetchedMessage[0].fromUser?.objectWillChange.send()
|
||||
|
|
@ -699,9 +700,11 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana
|
|||
fetchedMessage[0].realACK = true
|
||||
}
|
||||
}
|
||||
fetchedMessage[0].relayNode = Int64(packet.relayNode)
|
||||
fetchedMessage[0].ackError = Int32(routingMessage.errorReason.rawValue)
|
||||
if routingMessage.errorReason == Routing.Error.none {
|
||||
fetchedMessage[0].receivedACK = true
|
||||
fetchedMessage[0].relays += 1
|
||||
}
|
||||
|
||||
fetchedMessage[0].ackSNR = packet.rxSnr
|
||||
|
|
@ -944,6 +947,9 @@ func textMessageAppPacket(
|
|||
} else {
|
||||
newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970)
|
||||
}
|
||||
if packet.relayNode != 0 {
|
||||
newMessage.relayNode = Int64(packet.relayNode)
|
||||
}
|
||||
newMessage.receivedACK = false
|
||||
newMessage.snr = packet.rxSnr
|
||||
newMessage.rssi = packet.rxRssi
|
||||
|
|
@ -983,6 +989,7 @@ func textMessageAppPacket(
|
|||
newMessage.pkiEncrypted = true
|
||||
newMessage.publicKey = packet.publicKey
|
||||
}
|
||||
|
||||
/// Check for key mismatch
|
||||
if let nodeKey = newMessage.fromUser?.publicKey {
|
||||
if newMessage.toUser != nil && packet.pkiEncrypted && !packet.publicKey.isEmpty {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24299" systemVersion="25A354" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24299" systemVersion="25A362" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="AmbientLightingConfigEntity" representedClassName="AmbientLightingConfigEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="blue" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="current" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
|
|
@ -164,6 +164,8 @@
|
|||
<attribute name="read" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="realACK" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="receivedACK" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="relayNode" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="relays" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="replyID" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
|
||||
|
|
|
|||
|
|
@ -465,13 +465,17 @@ func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext)
|
|||
mutablePositions.add(position)
|
||||
fetchedNode[0].id = Int64(packet.from)
|
||||
fetchedNode[0].num = Int64(packet.from)
|
||||
if positionMessage.time > 0 {
|
||||
|
||||
// Update the node's lastHeard.
|
||||
// Some misconfigured nodes will broadcast position packets that claim GPS timestamps in the future. When updating lastHeard, don't use any future timestamps: fallback to using rxTime or Date() instead.
|
||||
if positionMessage.time > 0 && (Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time))) <= Date()) {
|
||||
fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(positionMessage.time)))
|
||||
} else if packet.rxTime > 0 {
|
||||
fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime)))
|
||||
} else {
|
||||
fetchedNode[0].lastHeard = Date()
|
||||
}
|
||||
|
||||
fetchedNode[0].snr = packet.rxSnr
|
||||
fetchedNode[0].rssi = packet.rxRssi
|
||||
fetchedNode[0].viaMqtt = packet.viaMqtt
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ struct MessageContextMenuItems: View {
|
|||
let isCurrentUser: Bool
|
||||
@Binding var isShowingDeleteConfirmation: Bool
|
||||
let onReply: () -> Void
|
||||
@State var relayDisplay: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
|
@ -19,6 +20,14 @@ struct MessageContextMenuItems: View {
|
|||
}
|
||||
Text("Channel") + Text(": \(message.channel)")
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let result = message.relayDisplay()
|
||||
DispatchQueue.main.async {
|
||||
relayDisplay = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Menu("Tapback") {
|
||||
ForEach(Tapbacks.allCases) { tb in
|
||||
|
|
@ -59,12 +68,27 @@ struct MessageContextMenuItems: View {
|
|||
}
|
||||
|
||||
Menu("Message Details") {
|
||||
// Precompute values to avoid executing non-View code inside the ViewBuilder
|
||||
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
|
||||
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
|
||||
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
|
||||
|
||||
// Compute a relay display string if relayNode is present
|
||||
|
||||
|
||||
VStack {
|
||||
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
|
||||
Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))").foregroundColor(.gray)
|
||||
Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 {
|
||||
if let relayDisplay {
|
||||
let prefix = message.realACK ? "Ack Relay: " : "Relay: "
|
||||
Text(prefix + relayDisplay)
|
||||
.foregroundColor(relayDisplay.contains("Node ") ? .gray : .primary)
|
||||
.font(relayDisplay.contains("Node ") ? .caption : .body)
|
||||
}
|
||||
|
||||
if !isCurrentUser && !(message.fromUser?.userNode?.viaMqtt ?? false) && message.fromUser?.userNode?.hopsAway ?? -1 == 0 {
|
||||
VStack {
|
||||
Text("SNR \(String(format: "%.2f", message.snr)) dB")
|
||||
Text("RSSI \(String(format: "%.2f", message.rssi)) dBm")
|
||||
|
|
@ -74,29 +98,29 @@ struct MessageContextMenuItems: View {
|
|||
Text("Hops Away \(message.fromUser?.userNode?.hopsAway ?? 0)")
|
||||
}
|
||||
}
|
||||
if message.relays != 0 && message.realACK == false {
|
||||
Text("Relayed by \(message.relays) \(message.relays == 1 ? "node" : "nodes")")
|
||||
}
|
||||
if isCurrentUser && message.receivedACK {
|
||||
VStack {
|
||||
Text("Received Ack") + Text(": \(message.receivedACK ? "✔️" : "")")
|
||||
Text("Recipient Ack") + Text(": \(message.realACK ? "✔️" : "")")
|
||||
Text("Received Ack: \(message.receivedACK ? "✔️" : "")")
|
||||
Text("Recipient Ack: \(message.realACK ? "✔️" : "")")
|
||||
}
|
||||
} else if isCurrentUser && message.ackError == 0 {
|
||||
// Empty Error
|
||||
Text("Waiting")
|
||||
} else if isCurrentUser && message.ackError > 0 {
|
||||
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
|
||||
Text("\(ackErrorVal?.display ?? "Empty Ack Error")")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if isCurrentUser {
|
||||
VStack {
|
||||
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
|
||||
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
|
||||
if ackDate >= sixMonthsAgo! {
|
||||
Text("Ack Time: \(ackDate.formattedDate(format: MessageText.timeFormatString))")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
if let sixMonthsAgo, ackDate >= sixMonthsAgo {
|
||||
Text("Ack Time: \(ackDate.formattedDate(format: MessageText.timeFormatString))")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
if message.ackSNR != 0 {
|
||||
VStack {
|
||||
Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB")
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ struct MeshMapContent: MapContent {
|
|||
@MapContentBuilder
|
||||
var positionAnnotations: some MapContent {
|
||||
ForEach(positions, id: \.id) { position in
|
||||
/// Apply favorits filter and don't show ignored nodes
|
||||
/// Apply favorites filter and don't show ignored nodes
|
||||
if (!showFavorites || (position.nodePosition?.favorite == true)) && !(position.nodePosition?.ignored == true) {
|
||||
let coordinateForNodePin: CLLocationCoordinate2D = if position.isPreciseLocation {
|
||||
// Precise location: place node pin at actual location.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue