diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 49242ba8..62456815 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -22614,7 +22614,7 @@ } } }, - "Positon config received: %@" : { + "Position config received: %@" : { "localizations" : { "de" : { "stringUnit" : { @@ -32156,6 +32156,9 @@ } } } + }, + "Trace Route (in %@s)" : { + }, "Trace Route Log" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 403492d2..66649df5 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 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 */; }; + 237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */; }; 251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; }; 251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; }; 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; }; @@ -294,6 +295,7 @@ 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = ""; }; + 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedButton.swift; sourceTree = ""; }; 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = ""; }; 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = ""; }; 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = ""; }; @@ -1041,6 +1043,7 @@ DD5E523D298F5A7D00D21B61 /* Weather */, DD6F65712C6AB8EC0053C113 /* SecureInput.swift */, 8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */, + 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */, ); path = Helpers; sourceTree = ""; @@ -1412,6 +1415,7 @@ DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */, DDE5B4042B2279A700FCDD05 /* TraceRouteLog.swift in Sources */, + 237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */, DD6193792863875F00E59241 /* SerialConfig.swift in Sources */, DDDB263F2AABEE20003AFCB7 /* NodeList.swift in Sources */, DDD5BB0B2C285E45007E03CA /* LogDetail.swift in Sources */, @@ -1785,7 +1789,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.2; + MARKETING_VERSION = 2.6.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1818,7 +1822,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.2; + MARKETING_VERSION = 2.6.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1849,7 +1853,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.2; + MARKETING_VERSION = 2.6.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1881,7 +1885,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.2; + MARKETING_VERSION = 2.6.3; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/Contents.json b/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/Contents.json new file mode 100644 index 00000000..7c46aedd --- /dev/null +++ b/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "progress.ring.dashed.svg", + "idiom" : "universal" + } + ] +} diff --git a/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/progress.ring.dashed.svg b/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/progress.ring.dashed.svg new file mode 100644 index 00000000..5d91388c --- /dev/null +++ b/Meshtastic/Assets.xcassets/progress.ring.dashed.symbolset/progress.ring.dashed.svg @@ -0,0 +1,169 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.6.0 + Requires Xcode 16 or greater + Generated from progress.ring.dashed + Typeset at 100.0 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift index 70f36c3d..e7abb191 100644 --- a/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift +++ b/Meshtastic/Extensions/CoreData/MessageEntityExtension.swift @@ -31,4 +31,11 @@ extension MessageEntity { return (try? context.fetch(fetchRequest)) ?? [MessageEntity]() } + + func displayTimestamp(aboveMessage: MessageEntity?) -> Bool { + if let aboveMessage = aboveMessage { + return aboveMessage.timestamp.addingTimeInterval(3600) < timestamp // 60 minutes + } + return false // First message will have no timestamp + } } diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index ba65f66a..e26d6245 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -940,7 +940,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate subtitle: "TR received back from \(destinationHop.name ?? "unknown")", content: "Hops from: \(tr.hopsTowards), Hops back: \(tr.hopsBack)\n\(tr.routeText ?? "Unknown".localized)\n\(tr.routeBackText ?? "Unknown".localized)", target: "nodes", - path: "meshtastic:///nodes?nodenum=\(connectedNode.user?.num ?? 0)" + path: "meshtastic:///nodes?nodenum=\(tr.node?.num ?? 0)" ) ] manager.schedule() @@ -1120,6 +1120,19 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if newMessage.toUser?.pkiEncrypted ?? false { meshPacket.pkiEncrypted = true meshPacket.publicKey = newMessage.toUser?.publicKey ?? Data() + // Auto Favorite nodes you DM so they don't roll out of the nodedb + if !(newMessage.toUser?.userNode?.favorite ?? true) { + newMessage.toUser?.userNode?.favorite = true + do { + try context.save() + Logger.data.info("💾 Auto favorited node bases on sending a message \(self.connectedPeripheral.num.toHex(), privacy: .public) to \(toUserNum.toHex(), privacy: .public)") + _ = self.setFavoriteNode(node: (newMessage.toUser?.userNode)!, connectedNodeNum: fromUserNum) + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Unresolved Core Data error when auto favoriting in Send Message Function. Error: \(nsError, privacy: .public)") + } + } } meshPacket.id = UInt32(newMessage.messageId) if toUserNum > 0 { diff --git a/Meshtastic/Helpers/LocationsHandler.swift b/Meshtastic/Helpers/LocationsHandler.swift index 75830805..f7166f70 100644 --- a/Meshtastic/Helpers/LocationsHandler.swift +++ b/Meshtastic/Helpers/LocationsHandler.swift @@ -8,6 +8,7 @@ import SwiftUI import CoreLocation import OSLog +import CoreData // Shared state that manages the `CLLocationManager` and `CLBackgroundActivitySession`. @MainActor class LocationsHandler: ObservableObject { @@ -109,15 +110,43 @@ import OSLog } else { locationsArray = [location] } + UserDefaults.standard.set(location.coordinate.latitude, forKey: "lastKnownLatitude") + UserDefaults.standard.set(location.coordinate.longitude, forKey: "lastKnownLongitude") + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastKnownLocationTimestamp") return true } - static let DefaultLocation = CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090) static var currentLocation: CLLocationCoordinate2D { - guard let location = shared.manager.location else { + if let location = shared.manager.location { + return location.coordinate + } else { + // Check authorization status + let status = shared.manager.authorizationStatus + switch status { + case .notDetermined: + Logger.services.info("📍 [App] Location permission not determined, requesting authorization") + shared.manager.requestWhenInUseAuthorization() + case .denied, .restricted: + Logger.services.warning("📍 [App] Location access denied or restricted. Please enable location services in Settings to get accurate positioning!") + shared.manager.requestWhenInUseAuthorization() + default: + break + } + // Fallback 1: Last known location from UserDefaults (if within 4 hours) + if let lat = UserDefaults.standard.object(forKey: "lastKnownLatitude") as? Double, + let lon = UserDefaults.standard.object(forKey: "lastKnownLongitude") as? Double, + let timestamp = UserDefaults.standard.object(forKey: "lastKnownLocationTimestamp") as? Double, + lat >= -90 && lat <= 90, + lon >= -180 && lon <= 180, + Date().timeIntervalSince1970 - timestamp <= 14_400 { // 4 hours in seconds + Logger.services.info("📍 [App] Falling back to last known location (age: \(Int(Date().timeIntervalSince1970 - timestamp)) seconds)") + return CLLocationCoordinate2D(latitude: lat, longitude: lon) + } + + // Fallback 2: Default location + Logger.services.warning("📍 [App] No Location and no last known location, something is really wrong. Teleporting user to Apple Park") return DefaultLocation } - return location.coordinate } static var satsInView: Int { diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index f5a7bda9..cb8608bd 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -1035,22 +1035,35 @@ func textMessageAppPacket( } } + func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { let logString = String.localizedStringWithFormat("Waypoint Packet received from node: %@".localized, String(packet.from)) Logger.mesh.info("📍 \(logString, privacy: .public)") - let fetchWaypointRequest = WaypointEntity.fetchRequest() - fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(packet.id)) - do { - if let waypointMessage = try? Waypoint(serializedBytes: packet.decoded.payload) { - let fetchedWaypoint = try context.fetch(fetchWaypointRequest) - if fetchedWaypoint.isEmpty { - let waypoint = WaypointEntity(context: context) + // Fetch waypoint by waypointMessage.id, not packet.id + let fetchWaypointRequest = WaypointEntity.fetchRequest() + fetchWaypointRequest.predicate = NSPredicate(format: "id == %lld", Int64(waypointMessage.id)) - waypoint.id = Int64(packet.id) + let fetchedWaypoint = try context.fetch(fetchWaypointRequest) + // Fetch the node info to get the short name + var nodeShortName: String = "?" + let fetchNodeRequest = NodeInfoEntity.fetchRequest() + fetchNodeRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) + do { + let fetchedNode = try context.fetch(fetchNodeRequest) + if let node = fetchedNode.first, let user = node.user { + nodeShortName = user.shortName ?? node.user?.userId ?? String(packet.from.toHex()) + } + } catch { + Logger.data.error("Failed to fetch NodeInfoEntity for node \(packet.from.toHex(), privacy: .public): \(error)") + } + if fetchedWaypoint.isEmpty { + // Create a new waypoint + let waypoint = WaypointEntity(context: context) + waypoint.id = Int64(waypointMessage.id) // Use waypointMessage.id waypoint.name = waypointMessage.name waypoint.longDescription = waypointMessage.description_p waypoint.latitudeI = waypointMessage.latitudeI @@ -1073,7 +1086,7 @@ func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { manager.notifications = [ Notification( id: ("notification.id.\(waypoint.id)"), - title: "New Waypoint Received", + title: "New Waypoint From \(nodeShortName)", subtitle: "\(icon) \(waypoint.name ?? "Dropped Pin")", content: "\(waypoint.longDescription ?? "\(latitude), \(longitude)")", target: "map", @@ -1088,26 +1101,42 @@ func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") } } else { - fetchedWaypoint[0].id = Int64(packet.id) - fetchedWaypoint[0].name = waypointMessage.name - fetchedWaypoint[0].longDescription = waypointMessage.description_p - fetchedWaypoint[0].latitudeI = waypointMessage.latitudeI - fetchedWaypoint[0].longitudeI = waypointMessage.longitudeI - fetchedWaypoint[0].icon = Int64(waypointMessage.icon) - fetchedWaypoint[0].locked = Int64(waypointMessage.lockedTo) - if waypointMessage.expire >= 1 { - fetchedWaypoint[0].expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) - } else { - fetchedWaypoint[0].expire = nil - } - fetchedWaypoint[0].lastUpdated = Date() - do { - try context.save() - Logger.data.info("💾 Updated Node Waypoint App Packet For: \(fetchedWaypoint[0].id, privacy: .public)") - } catch { - context.rollback() - let nsError = error as NSError - Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + // Update existing waypoint + let existingWaypoint = fetchedWaypoint[0] + if existingWaypoint.locked == 0 || existingWaypoint.locked == packet.from { + let currentTime = Int64(Date().timeIntervalSince1970) + if waypointMessage.expire > 0 && waypointMessage.expire <= currentTime { + context.delete(existingWaypoint) + do { + try context.save() + Logger.data.info("💾 Deleted a waypoint") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + } + } else { + existingWaypoint.name = waypointMessage.name + existingWaypoint.longDescription = waypointMessage.description_p + existingWaypoint.latitudeI = waypointMessage.latitudeI + existingWaypoint.longitudeI = waypointMessage.longitudeI + existingWaypoint.icon = Int64(waypointMessage.icon) + existingWaypoint.locked = Int64(waypointMessage.lockedTo) + if waypointMessage.expire >= 1 { + existingWaypoint.expire = Date(timeIntervalSince1970: TimeInterval(Int64(waypointMessage.expire))) + } else { + existingWaypoint.expire = nil + } + existingWaypoint.lastUpdated = Date() + do { + try context.save() + Logger.data.info("💾 Updated Node Waypoint App Packet For: \(existingWaypoint.id, privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Error Saving WaypointEntity from WAYPOINT_APP \(nsError, privacy: .public)") + } + } } } } diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 365faef4..f4c405bb 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -97,7 +97,22 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat if let targetValue = userInfo["target"] as? String, let deepLink = userInfo["path"] as? String, let url = URL(string: deepLink) { - Logger.services.info("userNotificationCenter didReceiveResponse \(targetValue, privacy: .public) \(deepLink, privacy: .public)") + Logger.services.info("userNotificationCenter didReceiveResponse handling deeplink: \(targetValue, privacy: .public) \(deepLink, privacy: .public)") + // Handle TraceRoute notifications specially to ensure they navigate correctly + if deepLink.contains("meshtastic:///nodes") && deepLink.contains("nodenum=") { + // First extract the node number from the URL + if let nodeNumString = deepLink.components(separatedBy: "nodenum=").last, + let nodeNum = Int64(nodeNumString) { + Logger.services.info("Navigation to specific node via notification: \(nodeNum, privacy: .public)") + self.router?.navigationState.selectedTab = .nodes + // Post a notification to trigger app-wide refresh + NotificationCenter.default.post(name: NSNotification.Name("ForceNavigationRefresh"), + object: nil, + userInfo: ["nodeNum": nodeNum]) + self.router?.navigationState.nodeListSelectedNodeNum = nodeNum + } + } + // Still call the regular router in all cases router?.route(url: url) } else { Logger.services.error("Failed to handle notification response: \(userInfo, privacy: .public)") diff --git a/Meshtastic/Views/Helpers/RateLimitedButton.swift b/Meshtastic/Views/Helpers/RateLimitedButton.swift new file mode 100644 index 00000000..30c5d667 --- /dev/null +++ b/Meshtastic/Views/Helpers/RateLimitedButton.swift @@ -0,0 +1,113 @@ +// +// RateLimitCountdownView.swift +// Meshtastic +// +// Created by Jake Bordens on 5/5/25. +// + +import SwiftUI + +// This class provides a rate limited button. +// Provide a key to differentiate which action is rate-limited +// This allows you to keep different rate limits for different action +// Rate limits are stored in a RateLimitStorage singleton, but do not persist +public struct RateLimitedButton: View { + typealias Builder = ((percentComplete: Double, secondsRemaining: TimeInterval)?) -> Content + + let key: String + + @StateObject var storage = RateLimitStorage.shared + + let rateLimit: TimeInterval + let content: Builder + let action: () -> Void + + init(key: String, rateLimit: TimeInterval, action: @escaping () -> Void, @ViewBuilder label: @escaping Builder) { + self.key = key + self.rateLimit = rateLimit + self.content = label + self.action = action + } + + public var body: some View { + let percentRemaining = storage.rateLimitRemainingPercentage(forKey: key) + let secondsRemaining = storage.rateLimitSecondsRemaining(forKey: key) + if percentRemaining > 0.0 { + content((percentRemaining, secondsRemaining)) + } else { + Button { + storage.actionOccured(forKey: key, rateLimit: rateLimit) + action() + } label: { + content(nil) + } + } + } +} + +// To store the time an action occured (name by a key) and the time limit +// Does not persist across app launches +class RateLimitStorage: ObservableObject { + private struct RateLimiter { + var actionOccuredTimestamp: Date + var rateLimitSeconds: TimeInterval + + var rateLimitExpires: Date { + return actionOccuredTimestamp.addingTimeInterval(rateLimitSeconds) + } + } + + static var shared: RateLimitStorage = RateLimitStorage() // Singleton instance + + private var rateLimits = [String: RateLimiter]() + private var timer: Timer? + + func actionOccured(forKey key: String, rateLimit: TimeInterval) { + let now = Date() + if let existingRateLimit = rateLimits[key] { + if existingRateLimit.rateLimitExpires > now.addingTimeInterval(rateLimit) { + // We have an existing rate limit that is larger than the one being requested + // Ignore + return + } + } + self.objectWillChange.send() + rateLimits[key] = RateLimiter(actionOccuredTimestamp: now, rateLimitSeconds: rateLimit) + startTimerIfNecessary() + } + + func rateLimitRemainingPercentage(forKey: String) -> Double { + guard let rateLimit = rateLimits[forKey] else { + return 0.0 + } + let percent = (rateLimit.rateLimitExpires.timeIntervalSinceNow) / rateLimit.rateLimitSeconds + return min(1.0, max(percent, 0.0)) + } + + func rateLimitSecondsRemaining(forKey: String) -> TimeInterval { + guard let rateLimit = rateLimits[forKey] else { + return 0.0 + } + return rateLimit.rateLimitExpires.timeIntervalSinceNow + } + + func startTimerIfNecessary() { + // Timer exists, don't create one + guard timer == nil else { return } + + // Create the timer + self.timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.objectWillChange.send() + + // Determine if we can clean up the dictionary and stop the timer. + let maxExpiration = self.rateLimits.values.map { $0.rateLimitExpires }.max() ?? .distantPast + if maxExpiration.timeIntervalSinceNow < 0 { + // All rateLimits are in the past. Stop and clean up + self.timer?.invalidate() + self.timer = nil + self.rateLimits.removeAll() + } + } + } +} diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index c82cfb0b..1cf8a1b0 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -33,8 +33,15 @@ struct ChannelMessageList: View { ZStack(alignment: .bottomTrailing) { ScrollView { LazyVStack { - ForEach(channel.allPrivateMessages) { (message: MessageEntity) in + ForEach(Array(channel.allPrivateMessages.enumerated()), id: \.element.id) { index, message in + // Get the previous message, if it exists + let previousMessage = index > 0 ? channel.allPrivateMessages[index - 1] : nil let currentUser: Bool = (Int64(preferredPeripheralNum) == message.fromUser?.num ? true : false) + if message.displayTimestamp(aboveMessage: previousMessage) { + Text(message.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundColor(.gray) + } if message.replyID > 0 { let messageReply = channel.allPrivateMessages.first(where: { $0.messageId == message.replyID }) HStack { diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index f03811a5..5f2d9b59 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -33,7 +33,14 @@ struct UserMessageList: View { ZStack(alignment: .bottomTrailing) { ScrollView { LazyVStack { - ForEach( user.messageList ) { (message: MessageEntity) in + ForEach( Array(user.messageList.enumerated()) , id: \.element.id) { index, message in + // Get the previous message, if it exists + let previousMessage = index > 0 ? user.messageList[index - 1] : nil + if message.displayTimestamp(aboveMessage: previousMessage) { + Text(message.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundColor(.gray) + } if user.num != bleManager.connectedPeripheral?.num ?? -1 { let currentUser: Bool = (Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num ?? -1 ? true : false) diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift index 64e2563a..fe5d8c87 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/TraceRouteButton.swift @@ -9,18 +9,28 @@ struct TraceRouteButton: View { private var isPresentingTraceRouteSentAlert: Bool = false var body: some View { - Button { + RateLimitedButton(key: "traceroute", rateLimit: 30.0) { isPresentingTraceRouteSentAlert = bleManager.sendTraceRouteRequest( destNum: node.user?.num ?? 0, wantResponse: true ) - } label: { - Label { - Text("Trace Route") - } icon: { - Image(systemName: "signpost.right.and.left") - .symbolRenderingMode(.hierarchical) - } + } label: { completion in + if let completion, completion.percentComplete > 0.0 { + Label { + Text("Trace Route (in \(completion.secondsRemaining.formatted(.number.precision(.fractionLength(0))))s)") + .foregroundStyle(.secondary) + } icon: { + Image("progress.ring.dashed", variableValue: completion.percentComplete) + .foregroundStyle(.secondary) + }.disabled(true) + } else { + Label { + Text("Trace Route") + } icon: { + Image(systemName: "signpost.right.and.left") + .symbolRenderingMode(.hierarchical) + } + } }.alert( "Trace Route Sent", isPresented: $isPresentingTraceRouteSentAlert diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 1e823020..a17a19d0 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -28,6 +28,8 @@ struct NodeList: View { @State private var isFavorite = false @State private var isIgnored = false @State private var isEnvironment = false + // Force refresh ID to make SwiftUI rebuild the view hierarchy + @State private var forceRefreshID = UUID() @State private var distanceFilter = false @State private var maxDistance: Double = 800000 @State private var hopsAway: Double = -1.0 @@ -91,21 +93,10 @@ struct NodeList: View { Label("Message", systemImage: "message") } } - Button { - let traceRouteSent = bleManager.sendTraceRouteRequest( - destNum: node.num, - wantResponse: true - ) - if traceRouteSent { - isPresentingTraceRouteSentAlert = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - isPresentingTraceRouteSentAlert = false - } - } - - } label: { - Label("Trace Route", systemImage: "signpost.right.and.left") - } + TraceRouteButton( + bleManager: bleManager, + node: node + ) Button { let positionSent = bleManager.sendPosition( channel: node.channel, @@ -142,6 +133,7 @@ struct NodeList: View { } var body: some View { + // Use forceRefreshID to completely rebuild the view when notifications update the selected node NavigationSplitView(columnVisibility: $columnVisibility) { List(nodes, id: \.self, selection: $selectedNode) { node in NodeListItem( @@ -326,16 +318,41 @@ struct NodeList: View { } .onChange(of: router.navigationState) { if let selected = router.navigationState.nodeListSelectedNodeNum { - self.selectedNode = getNodeInfo(id: selected, context: context) + // Force a complete view rebuild by generating a new UUID + Logger.services.info("Forcing view rebuild with new ID: \(self.forceRefreshID)") + // First clear selection + self.forceRefreshID = UUID() + self.selectedNode = nil + // Then after a short delay, set the new selection. Makes it obvious to use page is refreshing too. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + // Generate another UUID to ensure view gets rebuilt + self.forceRefreshID = UUID() + self.selectedNode = getNodeInfo(id: selected, context: context) + Logger.services.info("Complete view refresh with node: \(selected, privacy: .public)") + } } else { self.selectedNode = nil } } .onAppear { + // Set up notification observer for forced refreshes from notifications + NotificationCenter.default.addObserver(forName: NSNotification.Name("ForceNavigationRefresh"), object: nil, queue: .main) { notification in + if let nodeNum = notification.userInfo?["nodeNum"] as? Int64 { + // Force complete refresh of view + self.forceRefreshID = UUID() + self.selectedNode = getNodeInfo(id: nodeNum, context: self.context) + Logger.services.info("NodeList directly updated from notification for node: \(nodeNum, privacy: .public)") + } + } + Task { await searchNodeList() } } + .onDisappear { + // Remove observer when view disappears + NotificationCenter.default.removeObserver(self, name: NSNotification.Name("ForceNavigationRefresh"), object: nil) + } } private func searchNodeList() async { diff --git a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift index 82b4f628..81b43499 100644 --- a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift +++ b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift @@ -115,8 +115,7 @@ struct BluetoothConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty bluetooth config") - _ = bleManager.requestBluetoothConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/DeviceConfig.swift b/Meshtastic/Views/Settings/Config/DeviceConfig.swift index 6539848a..cd812e5d 100644 --- a/Meshtastic/Views/Settings/Config/DeviceConfig.swift +++ b/Meshtastic/Views/Settings/Config/DeviceConfig.swift @@ -273,8 +273,7 @@ struct DeviceConfig: View { } else { if node.deviceConfig == nil { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty device config") - _ = bleManager.requestDeviceConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/DisplayConfig.swift b/Meshtastic/Views/Settings/Config/DisplayConfig.swift index d6f14135..c9029408 100644 --- a/Meshtastic/Views/Settings/Config/DisplayConfig.swift +++ b/Meshtastic/Views/Settings/Config/DisplayConfig.swift @@ -178,8 +178,7 @@ struct DisplayConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty display config") - _ = bleManager.requestDisplayConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/LoRaConfig.swift b/Meshtastic/Views/Settings/Config/LoRaConfig.swift index 196f6ffb..1aae6ea4 100644 --- a/Meshtastic/Views/Settings/Config/LoRaConfig.swift +++ b/Meshtastic/Views/Settings/Config/LoRaConfig.swift @@ -248,13 +248,15 @@ struct LoRaConfig: View { /// 2.5 Administration with session passkey let expiration = node.sessionExpiration ?? Date() if expiration < Date() || node.loRaConfig == nil { + Logger.mesh.info("⚙️ Empty or expired lora config requesting via PKI admin") - _ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + if connectedNode.user != nil && node.user != nil { + _ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + } } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty lora config") - _ = bleManager.requestLoRaConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift index 24c0ada3..efbeed70 100644 --- a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift @@ -100,8 +100,7 @@ struct AmbientLightingConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty ambient lighting module config") - _ = bleManager.requestAmbientLightingConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index 20a62136..0fbcbcf8 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -248,8 +248,7 @@ struct CannedMessagesConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty canned messages module config") - _ = bleManager.requestCannedMessagesModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift index e042ca77..94b96b5d 100644 --- a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift @@ -206,8 +206,7 @@ struct DetectionSensorConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty detection sensor module config") - _ = bleManager.requestDetectionSensorModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift index e9959b41..08745f04 100644 --- a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift @@ -214,8 +214,7 @@ struct ExternalNotificationConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty external notificaiton module config") - _ = bleManager.requestExternalNotificationModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index 6967a2ab..bd2dfeeb 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -365,8 +365,7 @@ struct MQTTConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty mqtt module config") - _ = bleManager.requestMqttModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift index 6d71f7a7..7c84b406 100644 --- a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift @@ -73,8 +73,7 @@ struct PaxCounterConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty pax counter module config") - _ = bleManager.requestPaxCounterModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift index cc323170..b2636967 100644 --- a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift @@ -96,8 +96,7 @@ struct RangeTestConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty range test module config") - _ = bleManager.requestRangeTestModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift index 71452615..da30e1e4 100644 --- a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift @@ -87,8 +87,7 @@ struct RtttlConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty ringtone module config") - _ = bleManager.requestRtttlConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift index 0bbe9fbc..d1aca540 100644 --- a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift @@ -151,8 +151,7 @@ struct SerialConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty serial module config") - _ = bleManager.requestSerialModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift index 78ad1343..e124435a 100644 --- a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift @@ -152,8 +152,7 @@ struct StoreForwardConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty store & forward module config") - _ = bleManager.requestStoreAndForwardModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift index 0dfe7566..0ee48f86 100644 --- a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift @@ -149,8 +149,7 @@ struct TelemetryConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty telemetry module config") - _ = bleManager.requestTelemetryModuleConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/NetworkConfig.swift b/Meshtastic/Views/Settings/Config/NetworkConfig.swift index 35e25660..c4dcb8f2 100644 --- a/Meshtastic/Views/Settings/Config/NetworkConfig.swift +++ b/Meshtastic/Views/Settings/Config/NetworkConfig.swift @@ -158,8 +158,7 @@ struct NetworkConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty network config") - _ = bleManager.requestNetworkConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index 8fbd9d14..3af2546d 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -416,8 +416,7 @@ struct PositionConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty position config") - _ = bleManager.requestPositionConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/PowerConfig.swift b/Meshtastic/Views/Settings/Config/PowerConfig.swift index e3c26ffb..9ba385ff 100644 --- a/Meshtastic/Views/Settings/Config/PowerConfig.swift +++ b/Meshtastic/Views/Settings/Config/PowerConfig.swift @@ -143,8 +143,7 @@ struct PowerConfig: View { } } else { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty power config") - _ = bleManager.requestPowerConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } } diff --git a/Meshtastic/Views/Settings/Config/SecurityConfig.swift b/Meshtastic/Views/Settings/Config/SecurityConfig.swift index 938a9202..13f40f98 100644 --- a/Meshtastic/Views/Settings/Config/SecurityConfig.swift +++ b/Meshtastic/Views/Settings/Config/SecurityConfig.swift @@ -204,8 +204,7 @@ struct SecurityConfig: View { } else { if node.deviceConfig == nil { /// Legacy Administration - Logger.mesh.info("☠️ Using insecure legacy admin, empty security config") - _ = bleManager.requestSecurityConfig(fromUser: connectedNode.user!, toUser: node.user!, adminIndex: connectedNode.myInfo?.adminIndex ?? 0) + Logger.mesh.info("☠️ Using insecure legacy admin that is no longer supported, please upgrade your firmware.") } } }