Add shared contact functionality (WIP)

Feel free to hijack and make it not terrible
This commit is contained in:
Ben Meadors 2025-05-13 19:43:43 -05:00
parent e64a01e72f
commit 05397db3a4
7 changed files with 247 additions and 2 deletions

View file

@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
108FFECB2DD3F43C00BFAA81 /* ShareContactQRDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */; };
108FFECD2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */; };
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; };
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; };
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; };
@ -274,6 +276,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = "<group>"; };
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = "<group>"; };
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
@ -701,6 +705,7 @@
DD007BB12AA59B9A00F5FA12 /* CoreData */ = {
isa = PBXGroup;
children = (
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */,
2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */,
DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */,
6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */,
@ -1101,6 +1106,7 @@
DDDB26402AABEF7B003AFCB7 /* Helpers */ = {
isa = PBXGroup;
children = (
108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */,
231B3F232D087C020069A07D /* Metrics Columns */,
DDAD49EB2AFAE82500B4425D /* Map */,
DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */,
@ -1396,6 +1402,7 @@
DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */,
233E99C12D849D6000CC3A77 /* DistanceCompactWidget.swift in Sources */,
DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */,
108FFECD2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift in Sources */,
DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */,
DD15E4F32B8BA56E00654F61 /* PaxCounterConfig.swift in Sources */,
DDDB445229F8ACF900EE2349 /* Date.swift in Sources */,
@ -1486,6 +1493,7 @@
DDA1C48E28DB49D3009933EC /* ChannelRoles.swift in Sources */,
DDD5BB092C285DDC007E03CA /* AppLog.swift in Sources */,
DD8ED9C8289CE4B900B3B0AB /* RoutingError.swift in Sources */,
108FFECB2DD3F43C00BFAA81 /* ShareContactQRDialog.swift in Sources */,
233E99C52D84A0B600CC3A77 /* CompactWidget.swift in Sources */,
DDC1B81A2AB5377B00C71E39 /* MessagesTips.swift in Sources */,
DD964FC62975DBFD007C176F /* QueryCoreData.swift in Sources */,

View file

@ -0,0 +1,26 @@
// NodeInfoEntityToNodeInfo.swift
// Meshtastic
//
// Utility to convert NodeInfoEntity (Core Data) to NodeInfo (protobuf)
import Foundation
import MeshtasticProtobufs
extension NodeInfoEntity {
func toProto() -> NodeInfo {
var userProto = User()
if let user = self.user {
userProto.id = user.userId ?? ""
userProto.longName = user.longName ?? ""
userProto.shortName = user.shortName ?? ""
userProto.hwModel = HardwareModel(rawValue: Int(user.hwModelId))!; userProto.isLicensed = user.isLicensed
userProto.role = Config.DeviceConfig.Role(rawValue: Int(user.role))!
userProto.publicKey = user.publicKey?.subdata(in: 0..<user.publicKey!.count) ?? Data()
}
var node = NodeInfo()
node.num = UInt32(self.num)
node.user = userProto
// Add more fields as needed
return node
}
}

View file

@ -1770,6 +1770,55 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
return false
}
public func addContactFromURL(base64UrlString: String) -> Bool {
if isConnected {
let decodedString = base64UrlString.base64urlToBase64()
if let decodedData = Data(base64Encoded: decodedString) {
do {
let contact: SharedContact = try SharedContact(serializedBytes: decodedData)
var adminPacket = AdminMessage()
adminPacket.addContact = contact
var meshPacket: MeshPacket = MeshPacket()
meshPacket.to = UInt32(connectedPeripheral.num)
meshPacket.from = UInt32(connectedPeripheral.num)
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.priority = MeshPacket.Priority.reliable
meshPacket.wantAck = true
meshPacket.channel = 0
var dataMessage = DataMessage()
guard let adminData: Data = try? adminPacket.serializedData() else {
return false
}
dataMessage.payload = adminData
dataMessage.portnum = PortNum.adminApp
meshPacket.decoded = dataMessage
var toRadio: ToRadio!
toRadio = ToRadio()
toRadio.packet = meshPacket
guard let binaryData: Data = try? toRadio.serializedData() else {
return false
}
if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected {
self.connectedPeripheral.peripheral.writeValue(binaryData, for: self.TORADIO_characteristic, type: .withResponse)
let logString = String.localizedStringWithFormat("Sent a LoRa.Config for: %@".localized, String(connectedPeripheral.num))
Logger.mesh.info("📻 \(logString, privacy: .public)")
}
if self.connectedPeripheral != nil {
self.sendWantConfig()
return true
}
} catch {
return false
}
}
}
return false
}
public func saveUser(config: User, fromUser: UserEntity, toUser: UserEntity, adminIndex: Int32) -> Int64 {
var adminPacket = AdminMessage()
adminPacket.setOwner = config

View file

@ -7,6 +7,7 @@
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:meshtastic.org/e/*</string>
<string>applinks:meshtastic.org/v/*</string>
</array>
<key>com.apple.developer.weatherkit</key>
<true/>

View file

@ -4,6 +4,7 @@ import SwiftUI
import CoreData
import OSLog
import TipKit
import MeshtasticProtobufs
@main
struct MeshtasticAppleApp: App {
@ -87,7 +88,63 @@ struct MeshtasticAppleApp: App {
Logger.mesh.debug("Some sort of URL was received \(url, privacy: .public)")
self.incomingUrl = url
if url.absoluteString.lowercased().contains("meshtastic.org/e/#") {
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") {
// Extract contact information from the URL
if let contactData = components.last {
let decodedString = contactData.base64urlToBase64()
if let decodedData = Data(base64Encoded: decodedString) {
do {
let contact = try MeshtasticProtobufs.SharedContact(serializedBytes: decodedData)
// Show an alert to confirm adding the contact
let alertController = UIAlertController(
title: "Add Contact",
message: "Would you like to add \(contact.user.longName) as a contact?",
preferredStyle: .alert
)
alertController.addAction(UIAlertAction(
title: "Yes",
style: .default,
handler: { _ in
let success = BLEManager.shared.addContactFromURL(base64UrlString: contactData)
Logger.services.debug("Contact added from URL: \(success ? "success" : "failed")")
}
))
alertController.addAction(UIAlertAction(
title: "No",
style: .cancel,
handler: nil
))
// Present the alert
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(alertController, animated: true)
}
Logger.services.debug("Contact data extracted from URL: \(contactData, privacy: .public)")
} catch {
Logger.services.error("Failed to parse contact data: \(error.localizedDescription, privacy: .public)")
// Show error alert to user
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
let errorAlert = UIAlertController(
title: "Error",
message: "Could not process contact information. Invalid format.",
preferredStyle: .alert
)
errorAlert.addAction(UIAlertAction(title: "OK", style: .default))
rootViewController.present(errorAlert, animated: true)
}
}
}
}
}
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/#") {
if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") {
self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false
if self.incomingUrl?.absoluteString.lowercased().contains("?") != nil {
@ -119,7 +176,7 @@ struct MeshtasticAppleApp: App {
.displayFrequency(.immediate)
]
)
}
}
}
.onChange(of: scenePhase) { (_, newScenePhase) in
switch newScenePhase {

View file

@ -0,0 +1,91 @@
// ShareContactQRDialog.swift
// Meshtastic
//
// Created by GitHub Copilot on 5/13/25.
import SwiftUI
import CoreImage.CIFilterBuiltins
#if canImport(UIKit)
import UIKit
#endif
import CoreData
import MeshtasticProtobufs
import OSLog
struct ShareContactQRDialog: View {
let node: NodeInfo
@Environment(\.dismiss) private var dismiss
var qrString: String {
var contact = SharedContact()
contact.nodeNum = node.num
contact.user = node.user
do {
let contactString = try contact.serializedData().base64EncodedString()
return ("https://meshtastic.org/v/#" + contactString.base64ToBase64url())
} catch {
Logger.services.error("Error serializing contact: \(error)")
return "Error generating QR code"
}
}
var qrImage: UIImage {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.setValue(Data(qrString.utf8), forKey: "inputMessage")
let transform = CGAffineTransform(scaleX: 10, y: 10)
if let outputImage = filter.outputImage?.transformed(by: transform),
let cgimg = context.createCGImage(outputImage, from: outputImage.extent) {
return UIImage(cgImage: cgimg)
}
return UIImage(systemName: "xmark.circle") ?? UIImage()
}
var body: some View {
VStack(spacing: 20) {
Text("Share Contact QR")
.font(.title2)
.padding(.top)
Text(node.user.longName)
.font(.headline)
Image(uiImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: 220, height: 220)
.background(Color(.systemBackground))
.cornerRadius(16)
.shadow(radius: 4)
Text("Scan this QR code to add \(node.user.longName) to another device.")
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.padding(.bottom)
}
.padding()
.frame(maxWidth: 350)
}
}
#if DEBUG
struct ShareContactQRDialog_Previews: PreviewProvider {
static var previews: some View {
var node = NodeInfo()
node.num = 123456
var userProto = User()
userProto.id = "!1234"
userProto.longName = "Bud"
userProto.shortName = "Bud"
userProto.hwModel = HardwareModel(rawValue:1)!;
userProto.role = Config.DeviceConfig.Role(rawValue: 1)!
userProto.publicKey = Data()
node.user = userProto
return ShareContactQRDialog(node: node)
}
}
#endif

View file

@ -40,6 +40,8 @@ struct NodeList: View {
@State private var isPresentingPositionFailedAlert = false
@State private var isPresentingDeleteNodeAlert = false
@State private var deleteNodeId: Int64 = 0
@State private var isPresentingShareContactQR = false
@State private var shareContactNode: NodeInfoEntity?
var boolFilters: [Bool] {[
isFavorite,
@ -78,6 +80,12 @@ struct NodeList: View {
/// Allow users to mute notifications for a node even if they are not connected
if let user = node.user {
NodeAlertsButton(context: context, node: node, user: user)
Button(action: {
shareContactNode = node
isPresentingShareContactQR = true
}) {
Label("Share Contact QR", systemImage: "qrcode")
}
}
if let connectedNode {
/// Favoriting a node requires being connected
@ -223,6 +231,11 @@ struct NodeList: View {
}
}
}
}
.sheet(isPresented: $isPresentingShareContactQR) {
if let node = shareContactNode {
ShareContactQRDialog(node: node.toProto())
}
}
.navigationSplitViewColumnWidth(min: 100, ideal: 250, max: 500)
.navigationBarItems(