mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
exchange pos
This commit is contained in:
parent
b30dc8645a
commit
74d2e562f0
9 changed files with 620 additions and 153 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue