exchange pos

This commit is contained in:
Benjamin Faershtein 2026-01-17 14:47:26 -08:00
parent b30dc8645a
commit 74d2e562f0
9 changed files with 620 additions and 153 deletions

View file

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

View file

@ -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"
}
}
],

View file

@ -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)..<UInt32.max))
newMessage.messageTimestamp = Int32(Date().timeIntervalSince1970)
newMessage.receivedACK = false
newMessage.read = true
if destNum > 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? {

View file

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

View file

@ -170,6 +170,7 @@
<attribute name="rssi" optional="YES" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="snr" optional="YES" attributeType="Float" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="fromUser" optional="YES" maxCount="1" deletionRule="Nullify" ordered="YES" destinationEntity="UserEntity" inverseName="sentMessages" inverseEntity="UserEntity"/>
<relationship name="positionExchange" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="LocationEntity"/>
<relationship name="toUser" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserEntity" inverseName="receivedMessages" inverseEntity="UserEntity"/>
<uniquenessConstraints>
<uniquenessConstraint>

View file

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

View file

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

View file

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

View file

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