diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 33be032d..eaa72888 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -32139,6 +32139,12 @@ } } } + }, + "Send Position" : { + + }, + "Send Position Exchange" : { + }, "Send Reboot OTA" : { "localizations" : { @@ -33640,6 +33646,10 @@ } } }, + "Shared Location" : { + "comment" : "A label describing a location shared with another user.", + "isCommentAutoGenerated" : true + }, "Sharing Meshtastic Channels" : { "localizations" : { "de" : { @@ -42321,4 +42331,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b339cec..1ce62265 100644 --- a/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Meshtastic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2569905853aec088d5bac6b540eac77f78963f88b406e8dd95a88c40623cc8b4", + "originHash" : "295f50f25c1dce2f9776968206cbdeca800c36d0f49d4c77f36ddc3954798d2b", "pins" : [ { "identity" : "cocoamqtt", @@ -15,8 +15,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DataDog/dd-sdk-ios.git", "state" : { - "revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54", - "version" : "2.29.0" + "revision" : "c4dc12da013508db4d3dc2993faa4b1b3eb56fc9", + "version" : "3.5.0" + } + }, + { + "identity" : "kscrash", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kstenerud/KSCrash.git", + "state" : { + "revision" : "72e742c81d4ba03fab137e2651a1de342cdd8b3a", + "version" : "2.5.0" } }, { @@ -29,21 +38,12 @@ } }, { - "identity" : "opentelemetry-swift-packages", + "identity" : "opentelemetry-swift-core", "kind" : "remoteSourceControl", - "location" : "https://github.com/DataDog/opentelemetry-swift-packages.git", + "location" : "https://github.com/open-telemetry/opentelemetry-swift-core", "state" : { - "revision" : "4a7295600d4ebb9525a23c11586c5fdb74ae8b7e", - "version" : "1.13.1" - } - }, - { - "identity" : "plcrashreporter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/microsoft/plcrashreporter.git", - "state" : { - "revision" : "8c61e5e38e9f737dd68512ed1ea5ab081244ad65", - "version" : "1.12.0" + "revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f", + "version" : "2.3.0" } }, { @@ -55,13 +55,22 @@ "version" : "4.0.8" } }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, { "identity" : "swift-protobuf", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "102a647b573f60f73afdce5613a51d71349fe507", - "version" : "1.30.0" + "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", + "version" : "1.33.3" } } ], diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift index e7c8cd4f..7f8d0580 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+Position.swift @@ -9,6 +9,7 @@ import Foundation import OSLog import MeshtasticProtobufs import CoreLocation +import CoreData extension AccessoryManager { func initializeLocationProvider() { @@ -27,38 +28,124 @@ extension AccessoryManager { } } - public func sendPosition(channel: Int32, destNum: Int64, hopsAway: Int32 = 0, wantResponse: Bool) async throws { + public func sendPosition(channel: Int32, destNum: Int64, hopsAway: Int32 = 0, wantResponse: Bool,context: NSManagedObjectContext? = nil) async throws { guard let fromNodeNum = activeConnection?.device.num else { throw AccessoryError.ioFailed("Not connected to any device") } + + print("Sending with want response \(wantResponse)") guard let positionPacket = try await getPositionFromPhoneGPS(destNum: destNum, fixedPosition: false) else { Logger.services.error("Unable to get position data from device GPS to send to node") throw AccessoryError.appError("Unable to get position data from device GPS to send to node") } - - var meshPacket = MeshPacket() - meshPacket.to = UInt32(destNum) - meshPacket.channel = UInt32(channel) - meshPacket.from = UInt32(fromNodeNum) - if hopsAway > 0 { - meshPacket.hopLimit = UInt32(truncatingIfNeeded: hopsAway) - } - var dataMessage = DataMessage() - if let serializedData: Data = try? positionPacket.serializedData() { - dataMessage.payload = serializedData - dataMessage.portnum = PortNum.positionApp - dataMessage.wantResponse = wantResponse - meshPacket.decoded = dataMessage - } else { - Logger.services.error("Failed to serialize position packet data") - throw AccessoryError.ioFailed("sendPosition: Unable to serialize position packet data") + + // Fetch the users involved in this position share + let messageUsers = UserEntity.fetchRequest() + messageUsers.predicate = NSPredicate(format: "num IN %@", [fromNodeNum, destNum]) + + guard let context = context else { + throw AccessoryError.ioFailed("No context available") } - var toRadio: ToRadio! - toRadio = ToRadio() - toRadio.packet = meshPacket - try await self.send(toRadio) + do { + let fetchedUsers = try context.fetch(messageUsers) + if fetchedUsers.isEmpty { + throw AccessoryError.ioFailed("Message Users Not Found") + } + + // Create a LocationEntity from the position data + let locationEntity = LocationEntity(context: context) + locationEntity.latitudeI = positionPacket.latitudeI + locationEntity.longitudeI = positionPacket.longitudeI + locationEntity.altitude = positionPacket.altitude + locationEntity.speed = Int32(positionPacket.groundSpeed) + locationEntity.heading = Int32(positionPacket.groundTrack) + + // Create the MessageEntity for the position share + let newMessage = MessageEntity(context: context) + newMessage.messageId = Int64(UInt32.random(in: UInt32(UInt8.max).. 0 { + newMessage.toUser = fetchedUsers.first(where: { $0.num == destNum }) + newMessage.toUser?.lastMessage = Date() +// if newMessage.toUser?.pkiEncrypted ?? false { +// newMessage.publicKey = newMessage.toUser?.publicKey +// newMessage.pkiEncrypted = true +// } + } + + newMessage.fromUser = fetchedUsers.first(where: { $0.num == fromNodeNum }) + newMessage.isEmoji = false + newMessage.admin = false + newMessage.channel = channel + newMessage.messagePayload = "" // Empty for position messages + newMessage.messagePayloadMarkdown = "" // Empty for position messages + newMessage.positionExchange = locationEntity // Link the location + newMessage.read = true + + // Prepare the mesh packet + var meshPacket = MeshPacket() + meshPacket.to = UInt32(destNum) + meshPacket.channel = UInt32(channel) + meshPacket.from = UInt32(fromNodeNum) + meshPacket.id = UInt32(newMessage.messageId) + + if hopsAway > 0 { + meshPacket.hopLimit = UInt32(truncatingIfNeeded: hopsAway) + } else { + let toUserHopsAway = newMessage.toUser?.userNode?.hopsAway ?? 0 + if toUserHopsAway > Int32(truncatingIfNeeded: newMessage.fromUser?.userNode?.loRaConfig?.hopLimit ?? 0) { + meshPacket.hopLimit = UInt32(truncatingIfNeeded: toUserHopsAway) + } + } +// +// if newMessage.toUser?.pkiEncrypted ?? false { +// meshPacket.pkiEncrypted = true +// meshPacket.publicKey = newMessage.toUser?.publicKey ?? Data() +// } + + var dataMessage = DataMessage() + if let serializedData: Data = try? positionPacket.serializedData() { + dataMessage.payload = serializedData + dataMessage.portnum = PortNum.positionApp + dataMessage.wantResponse = wantResponse + meshPacket.decoded = dataMessage + } else { + Logger.services.error("Failed to serialize position packet data") + throw AccessoryError.ioFailed("sendPosition: Unable to serialize position packet data") + } + + + + meshPacket.wantAck = true + + var toRadio: ToRadio! + toRadio = ToRadio() + toRadio.packet = meshPacket + + Task { + let logString = String.localizedStringWithFormat("Sent position message %@ from %@ to %@".localized, String(newMessage.messageId), fromNodeNum.toHex(), destNum.toHex()) + try await send(toRadio, debugDescription: logString) + } + + do { + try context.save() + Logger.data.info("πŸ’Ύ Saved a new position message from \(fromNodeNum.toHex(), privacy: .public) to \(destNum.toHex(), privacy: .public)") + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Unresolved Core Data error in Send Position Function. Error: \(nsError, privacy: .public)") + throw error + } + + } catch { + Logger.data.error("πŸ’₯ Send position message failure from \(fromNodeNum.toHex(), privacy: .public) to \(destNum.toHex(), privacy: .public)") + throw error + } } public func getPositionFromPhoneGPS(destNum: Int64, fixedPosition: Bool) async throws -> Position? { diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 07513866..b4ae1634 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -508,7 +508,7 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { case .remoteHardwareApp: Logger.mesh.info("πŸ•ΈοΈ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)") case .positionApp: - upsertPositionPacket(packet: packet, context: context) + upsertPositionPacket(packet: packet, context: context, myNodeNum: self.activeDeviceNum ?? 0,appState: appState) case .waypointApp: waypointPacket(packet: packet, context: context) case .nodeinfoApp: diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents index a6e5465f..d68a3b18 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents @@ -170,6 +170,7 @@ + diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index c56644a5..8e869027 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -446,10 +446,11 @@ func upsertNodeInfoPacket (packet: MeshPacket, favorite: Bool = false, context: } } -func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) { +func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext, myNodeNum: Int64, appState: AppState) { let logString = String.localizedStringWithFormat("[Position] received from node: %@".localized, String(packet.from)) Logger.mesh.info("πŸ“ \(logString, privacy: .public)") + print(":") let fetchNodePositionRequest = NodeInfoEntity.fetchRequest() fetchNodePositionRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from)) @@ -510,6 +511,20 @@ func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) fetchedNode[0].channel = Int32(packet.channel) fetchedNode[0].positions = mutablePositions.copy() as? NSOrderedSet + + print("packet to me: \(packet.to == myNodeNum)") + if packet.to == myNodeNum { + createPositionMessageEntity( + context: context, + packet: packet, + positionMessage: positionMessage, + positionEntity: position, + fromNodeNum: Int64(packet.from), + toNodeNum: myNodeNum, + connectedNode: myNodeNum, + appState: appState + ) + } do { try context.save() @@ -529,6 +544,193 @@ func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) } } +private func createPositionMessageEntity( + context: NSManagedObjectContext, + packet: MeshPacket, + positionMessage: Position, + positionEntity: PositionEntity, + fromNodeNum: Int64, + toNodeNum: Int64, + connectedNode: Int64, + appState: AppState?, +) { + Logger.mesh.info("πŸ“ \("Position message received and creating message entity".localized, privacy: .public)") + + let messageUsers = UserEntity.fetchRequest() + messageUsers.predicate = NSPredicate(format: "num IN %@", [packet.to, packet.from]) + + do { + let fetchedUsers = try context.fetch(messageUsers) + let newMessage = MessageEntity(context: context) + newMessage.messageId = Int64(packet.id) + + if packet.rxTime > 0 { + newMessage.messageTimestamp = Int32(bitPattern: packet.rxTime) + } 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 + newMessage.isEmoji = false + newMessage.channel = Int32(packet.channel) + newMessage.portNum = Int32(packet.decoded.portnum.rawValue) + newMessage.read = false + + if packet.decoded.replyID > 0 { + newMessage.replyID = Int64(packet.decoded.replyID) + } + + // Create a LocationEntity from the position + let locationEntity = LocationEntity(context: context) + locationEntity.latitudeI = positionMessage.latitudeI + locationEntity.longitudeI = positionMessage.longitudeI + locationEntity.altitude = positionMessage.altitude + locationEntity.speed = Int32(positionMessage.groundSpeed) + locationEntity.heading = Int32(positionMessage.groundTrack) + + newMessage.positionExchange = locationEntity + newMessage.messagePayload = "" + newMessage.messagePayloadMarkdown = "" + newMessage.admin = false + + // Handle toUser - only set if it's a direct message (not broadcast) + if fetchedUsers.first(where: { $0.num == packet.to }) != nil && packet.to != Constants.maximumNodeNum { + newMessage.toUser = fetchedUsers.first(where: { $0.num == packet.to }) + } else if packet.to != Constants.maximumNodeNum { + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.to), context: context) + newMessage.toUser = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.to, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.to, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + + // Handle fromUser + if fetchedUsers.first(where: { $0.num == packet.from }) != nil { + newMessage.fromUser = fetchedUsers.first(where: { $0.num == packet.from }) + } else { + // Make a new from user if they are unknown + do { + let newUser = try createUser(num: Int64(truncatingIfNeeded: packet.from), context: context) + let newNode = NodeInfoEntity(context: context) + newNode.id = Int64(newUser.num) + newNode.num = Int64(newUser.num) + newNode.user = newUser + newMessage.fromUser = newUser + } catch CoreDataError.invalidInput(let message) { + Logger.data.error("Error Creating a new Core Data UserEntity (Invalid Input) from node number: \(packet.from, privacy: .public) Error: \(message, privacy: .public)") + } catch { + Logger.data.error("Error Creating a new Core Data UserEntity from node number: \(packet.from, privacy: .public) Error: \(error.localizedDescription, privacy: .public)") + } + } + + // Update lastHeard timestamp + if packet.rxTime > 0 { + newMessage.fromUser?.userNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime))) + } else { + newMessage.fromUser?.userNode?.lastHeard = Date() + } + + // Update lastMessage for DMs + if packet.to != Constants.maximumNodeNum && newMessage.fromUser != nil { + newMessage.fromUser?.lastMessage = Date() + } + + var messageSaved = false + do { + try context.save() + Logger.data.info("πŸ’Ύ Saved a new position message for \(newMessage.messageId, privacy: .public)") + messageSaved = true + } catch { + context.rollback() + let nsError = error as NSError + Logger.data.error("Failed to save new position MessageEntity \(nsError, privacy: .public)") + } + + // Send notifications if the message saved properly to core data + if messageSaved { + if newMessage.fromUser != nil && newMessage.toUser != nil { + // Set Unread Message Indicators for DM + if packet.to == connectedNode { + let unreadCount = newMessage.toUser?.unreadMessages(context: context, skipLastMessageCheck: true) ?? 0 + Task { @MainActor in + appState?.unreadDirectMessages = unreadCount + } + } + + if !(newMessage.fromUser?.mute ?? false) { + // Create an iOS Notification for the received DM position + let manager = LocalNotificationManager() + let notificationContent = "πŸ“ Shared their location" + manager.notifications = [ + Notification( + id: ("notification.id.\(newMessage.messageId)"), + title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)", + subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", + content: notificationContent, + target: "messages", + path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)", + messageId: newMessage.messageId, + channel: newMessage.channel, + userNum: Int64(packet.from), + critical: false + ) + ] + manager.schedule() + Logger.services.debug("iOS Notification Scheduled for position message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)") + } + } else if newMessage.fromUser != nil && newMessage.toUser == nil { + // Handle channel position messages (broadcast) + let fetchMyInfoRequest = MyInfoEntity.fetchRequest() + fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedNode)) + do { + let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) + if !fetchedMyInfo.isEmpty { + appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages(context: context) + for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] { + if channel.index == newMessage.channel { + context.refresh(channel, mergeChanges: true) + } + if channel.index == newMessage.channel && !channel.mute && UserDefaults.channelMessageNotifications { + // Create an iOS Notification for the received channel position + let manager = LocalNotificationManager() + let notificationContent = "πŸ“ Shared their location" + manager.notifications = [ + Notification( + id: ("notification.id.\(newMessage.messageId)"), + title: "\(newMessage.fromUser?.longName ?? "Unknown".localized)", + subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", + content: notificationContent, + target: "messages", + path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)", + messageId: newMessage.messageId, + channel: newMessage.channel, + userNum: Int64(newMessage.fromUser?.userId ?? "0"), + critical: false + ) + ] + manager.schedule() + Logger.services.debug("iOS Notification Scheduled for channel position message from \(newMessage.fromUser?.longName ?? "Unknown".localized, privacy: .public)") + } + } + } + } catch { + Logger.data.error("Failed to fetch MyInfo for channel position notifications: \(error.localizedDescription, privacy: .public)") + } + } + } + } catch { + Logger.data.error("Fetch Message To and From Users Error for position: \(error.localizedDescription, privacy: .public)") + } +} func upsertBluetoothConfigPacket(config: Config.BluetoothConfig, nodeNum: Int64, sessionPasskey: Data? = Data(), context: NSManagedObjectContext) { let logString = String.localizedStringWithFormat("Bluetooth config received: %@".localized, String(nodeNum)) diff --git a/Meshtastic/Views/Messages/MessageText.swift b/Meshtastic/Views/Messages/MessageText.swift index 28df8fba..66f183c6 100644 --- a/Meshtastic/Views/Messages/MessageText.swift +++ b/Meshtastic/Views/Messages/MessageText.swift @@ -2,6 +2,7 @@ import MeshtasticProtobufs import OSLog import SwiftUI import DatadogSessionReplay +import MapKit struct MessageText: View { static let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */ @@ -27,64 +28,17 @@ struct MessageText: View { // State for handling channel URL sheet @State private var saveChannelLink: SaveChannelLinkData? @State private var isShowingDeleteConfirmation = false + @State private var shouldNavigateToMap = false var body: some View { - - SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) { - - let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) - return Text(markdownText) - .tint(Self.linkBlue) - .padding(.vertical, 10) - .padding(.horizontal, 8) - .foregroundColor(.white) - .background(isCurrentUser ? .accentColor : Color(.gray)) - .cornerRadius(15) - .overlay { - /// Show the lock if the message is pki encrypted and has a real ack if sent by the current user, or is pki encrypted for incoming messages - if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted { - VStack(alignment: .trailing) { - Spacer() - HStack { - Spacer() - Image(systemName: "lock.circle.fill") - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .green) - .font(.system(size: 20)) - .offset(x: 8, y: 8) - } - } - } - let isStoreAndForward = message.portNum == Int32(PortNum.storeForwardApp.rawValue) - let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) - if isStoreAndForward { - VStack(alignment: .trailing) { - Spacer() - HStack { - Spacer() - Image(systemName: "envelope.circle.fill") - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .gray) - .font(.system(size: 20)) - .offset(x: 8, y: 8) - } - } - } - if tapBackDestination.overlaySensorMessage { - VStack { - isDetectionSensorMessage ? Image(systemName: "sensor.fill") - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) - .foregroundStyle(Color.orange) - .symbolRenderingMode(.multicolor) - .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) - .offset(x: 20, y: -20) - : nil - } - } else { - EmptyView() - } - } + NavigationStack{ + if let positionExchange = message.positionExchange, let node = message.fromUser?.userNode { + PositionMessageView( + location: positionExchange, + isCurrentUser: isCurrentUser, + message: message, + onTap: { shouldNavigateToMap = true } + ) .contextMenu { MessageContextMenuItems( message: message, @@ -94,44 +48,6 @@ struct MessageText: View { onReply: onReply ) } - .environment(\.openURL, OpenURLAction { url in - saveChannelLink = nil - var addChannels = false - if url.absoluteString.lowercased().contains("meshtastic.org/v/#") { - // Handle contact URL - ContactURLHandler.handleContactUrl(url: url, accessoryManager: AccessoryManager.shared) - return .handled // Prevent default browser opening - } else if url.absoluteString.lowercased().contains("meshtastic.org/e/") { - // Handle channel URL - let components = url.absoluteString.components(separatedBy: "#") - guard !components.isEmpty, let lastComponent = components.last else { - Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)") - return .discarded - } - addChannels = Bool(url.query?.contains("add=true") ?? false) - guard let lastComponent = components.last else { - Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)") - self.saveChannelLink = nil - return .discarded - } - let cs = lastComponent.components(separatedBy: "?").first ?? "" - self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels) - Logger.services.debug("Add Channel: \(addChannels, privacy: .public)") - Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)") - return .handled // Prevent default browser opening - } - return .systemAction // Open other URLs in browser - }) - // Display sheet for channel settings - .sheet(item: $saveChannelLink) { link in - SaveChannelQRCode( - channelSetLink: link.data, - addChannels: link.add, - accessoryManager: accessoryManager - ) - .presentationDetents([.large]) - .presentationDragIndicator(.visible) - } .confirmationDialog( "Are you sure you want to delete this message?", isPresented: $isShowingDeleteConfirmation, @@ -147,6 +63,236 @@ struct MessageText: View { } Button("Cancel", role: .cancel) {} } + .navigationDestination(isPresented: $shouldNavigateToMap) { + NodeMapSwiftUI(node: node, showUserLocation: true) + .onDisappear { shouldNavigateToMap = false } + } + } else { + SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) { + + let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE")) + return Text(markdownText) + .tint(Self.linkBlue) + .padding(.vertical, 10) + .padding(.horizontal, 8) + .foregroundColor(.white) + .background(isCurrentUser ? .accentColor : Color(.gray)) + .cornerRadius(15) + .overlay { + /// Show the lock if the message is pki encrypted and has a real ack if sent by the current user, or is pki encrypted for incoming messages + if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted { + VStack(alignment: .trailing) { + Spacer() + HStack { + Spacer() + Image(systemName: "lock.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .green) + .font(.system(size: 20)) + .offset(x: 8, y: 8) + } + } + } + let isStoreAndForward = message.portNum == Int32(PortNum.storeForwardApp.rawValue) + let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue) + if isStoreAndForward { + VStack(alignment: .trailing) { + Spacer() + HStack { + Spacer() + Image(systemName: "envelope.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .gray) + .font(.system(size: 20)) + .offset(x: 8, y: 8) + } + } + } + if tapBackDestination.overlaySensorMessage { + VStack { + isDetectionSensorMessage ? Image(systemName: "sensor.fill") + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .foregroundStyle(Color.orange) + .symbolRenderingMode(.multicolor) + .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3)) + .offset(x: 20, y: -20) + : nil + } + } else { + EmptyView() + } + } + .contextMenu { + MessageContextMenuItems( + message: message, + tapBackDestination: tapBackDestination, + isCurrentUser: isCurrentUser, + isShowingDeleteConfirmation: $isShowingDeleteConfirmation, + onReply: onReply + ) + } + .environment(\.openURL, OpenURLAction { url in + saveChannelLink = nil + var addChannels = false + if url.absoluteString.lowercased().contains("meshtastic.org/v/#") { + // Handle contact URL + ContactURLHandler.handleContactUrl(url: url, accessoryManager: AccessoryManager.shared) + return .handled // Prevent default browser opening + } else if url.absoluteString.lowercased().contains("meshtastic.org/e/") { + // Handle channel URL + let components = url.absoluteString.components(separatedBy: "#") + guard !components.isEmpty, let lastComponent = components.last else { + Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)") + return .discarded + } + addChannels = Bool(url.query?.contains("add=true") ?? false) + guard let lastComponent = components.last else { + Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)") + self.saveChannelLink = nil + return .discarded + } + let cs = lastComponent.components(separatedBy: "?").first ?? "" + self.saveChannelLink = SaveChannelLinkData(data: cs, add: addChannels) + Logger.services.debug("Add Channel: \(addChannels, privacy: .public)") + Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)") + return .handled // Prevent default browser opening + } + return .systemAction // Open other URLs in browser + }) + // Display sheet for channel settings + .sheet(item: $saveChannelLink) { link in + SaveChannelQRCode( + channelSetLink: link.data, + addChannels: link.add, + accessoryManager: accessoryManager + ) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .confirmationDialog( + "Are you sure you want to delete this message?", + isPresented: $isShowingDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete Message", role: .destructive) { + context.delete(message) + do { + try context.save() + } catch { + Logger.data.error("Failed to delete message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + Button("Cancel", role: .cancel) {} + } + } + } + }} +} + +// New view component for displaying position messages +struct PositionMessageView: View { + let location: LocationEntity + let isCurrentUser: Bool + let message: MessageEntity + let onTap: () -> Void + + @State private var region: MKCoordinateRegion + + init(location: LocationEntity, isCurrentUser: Bool, message: MessageEntity, onTap: @escaping () -> Void) { + self.location = location + self.isCurrentUser = isCurrentUser + self.message = message + self.onTap = onTap + + // Convert location coordinates from Int32 to CLLocationDegrees + let latitude = Double(location.latitudeI) / 1e7 + let longitude = Double(location.longitudeI) / 1e7 + + _region = State(initialValue: MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01) + )) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + // Mini map + Map(position: .constant(.region(region))) { + let coordinate = CLLocationCoordinate2D( + latitude: Double(location.latitudeI) / 1e7, + longitude: Double(location.longitudeI) / 1e7 + ) + Annotation("", coordinate: coordinate) { + Image(systemName: "pin.circle.fill") + .foregroundStyle(Color(UIColor(hex: UInt32(message.fromUser?.num ?? 0)))) + } + } + .frame(width: 200, height: 150) + .cornerRadius(10) + + // Location details + VStack(alignment: .leading, spacing: 2) { + HStack { + Image(systemName: "location.fill") + .font(.system(size: 12)) + Text("Shared Location") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(.white.opacity(0.9)) + + if location.altitude != 0 { + HStack(spacing: 4) { + Image(systemName: "arrow.up") + .font(.system(size: 10)) + let altitudeMeters = Measurement(value: Double(location.altitude), unit: UnitLength.meters) + let altitudeFeet = altitudeMeters.converted(to: .feet) + if Locale.current.measurementSystem == .metric { + Text(altitudeFormatter.string(from: altitudeMeters)) + .font(.system(size: 11)) + } else { + Text(altitudeFormatter.string(from: altitudeFeet)) + .font(.system(size: 11)) + } + } + .foregroundColor(.white.opacity(0.8)) + } + + if location.speed > 0 { + HStack(spacing: 4) { + Image(systemName: "speedometer") + .font(.system(size: 10)) + let speedKmh = Measurement(value: Double(location.speed), unit: UnitSpeed.kilometersPerHour) + Text(speedKmh.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0))))) + .font(.system(size: 11)) + } + .foregroundColor(.white.opacity(0.8)) + } + } + .padding(.horizontal, 8) + .padding(.bottom, 6) + } + .background(isCurrentUser ? .accentColor : Color(.gray)) + .cornerRadius(15) + .contentShape(Rectangle()) + .onTapGesture { + onTap() + } + .overlay { + /// Show the lock if the message is pki encrypted + if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted { + VStack(alignment: .trailing) { + Spacer() + HStack { + Spacer() + Image(systemName: "lock.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .green) + .font(.system(size: 20)) + .offset(x: 8, y: 8) + } + } + } } } } @@ -159,3 +305,4 @@ private extension MessageDestination { } } } + diff --git a/Meshtastic/Views/Messages/RetryButton.swift b/Meshtastic/Views/Messages/RetryButton.swift index ab48072b..ea6ee75e 100644 --- a/Meshtastic/Views/Messages/RetryButton.swift +++ b/Meshtastic/Views/Messages/RetryButton.swift @@ -33,6 +33,7 @@ struct RetryButton: View { let channel = message.channel let isEmoji = message.isEmoji let replyID = message.replyID + let positionExchange = message.positionExchange context.delete(message) do { try context.save() @@ -41,8 +42,16 @@ struct RetryButton: View { } Task { do { - try await accessoryManager.sendMessage(message: payload, toUserNum: userNum, channel: channel, - isEmoji: isEmoji, replyID: replyID) + if positionExchange != nil { + var wantResponse: Bool = true + if Int64(Constants.maximumNodeNum) == userNum { + wantResponse = false + } + try await accessoryManager.sendPosition(channel: channel, destNum: userNum, wantResponse: wantResponse,context: context) + } else { + try await accessoryManager.sendMessage(message: payload, toUserNum: userNum, channel: channel, + isEmoji: isEmoji, replyID: replyID) + } if case let .channel(channel) = destination { // We must refresh the channel to trigger a view update since its relationship // to messages is via a weak fetched property which is not updated by diff --git a/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift b/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift index e13ac808..977eadcd 100644 --- a/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift +++ b/Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift @@ -14,7 +14,7 @@ struct TextMessageField: View { @State private var typingMessage: String = "" @State private var totalBytes = 0 - @State private var sendPositionWithMessage = false + @State private var showingPositionConfirmation = false var body: some View { SessionReplayPrivacyView(textAndInputPrivacy: .maskAllInputs) { @@ -75,7 +75,7 @@ struct TextMessageField: View { Spacer() AlertButton { typingMessage += "πŸ”” Alert Bell Character! \u{7}" } Spacer() - RequestPositionButton(action: requestPosition) + RequestPositionButton(action: {showingPositionConfirmation = true}) Spacer() TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes) } @@ -91,7 +91,8 @@ struct TextMessageField: View { Spacer() AlertButton { typingMessage += "πŸ”” Alert Bell Character! \u{7}" } Spacer() - RequestPositionButton(action: requestPosition) + RequestPositionButton(action: {showingPositionConfirmation = true}) + Spacer() TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes) } @@ -101,13 +102,22 @@ struct TextMessageField: View { } } } + .confirmationDialog("Send Position", isPresented: $showingPositionConfirmation) { + Button("Send Position Exchange") {requestPosition()} + Button("Cancel", role: .cancel) { } + } } } private func requestPosition() { - let userLongName = accessoryManager.activeConnection?.device.longName ?? "Unknown" - sendPositionWithMessage = true - typingMessage = "πŸ“ " + userLongName + " \(destination.positionShareMessage)." + Task{ + try await accessoryManager.sendPosition( + channel: destination.channelNum, + destNum: destination.positionDestNum, + wantResponse: destination.wantPositionResponse,context: accessoryManager.context + ) + Logger.mesh.info("Location Sent") + } } private func sendMessage() { @@ -124,14 +134,6 @@ struct TextMessageField: View { isFocused = false replyMessageId = 0 - if sendPositionWithMessage { - try await accessoryManager.sendPosition( - channel: destination.channelNum, - destNum: destination.positionDestNum, - wantResponse: destination.wantPositionResponse - ) - Logger.mesh.info("Location Sent") - } } catch { Logger.mesh.info("Error sending message") }