From 05397db3a4a4339fb626a19c7e2ae95dc6316100 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 13 May 2025 19:43:43 -0500 Subject: [PATCH] Add shared contact functionality (WIP) Feel free to hijack and make it not terrible --- Meshtastic.xcodeproj/project.pbxproj | 8 ++ .../CoreData/NodeInfoEntityToNodeInfo.swift | 26 ++++++ Meshtastic/Helpers/BLEManager.swift | 49 ++++++++++ Meshtastic/Meshtastic.entitlements | 1 + Meshtastic/MeshtasticApp.swift | 61 ++++++++++++- .../Nodes/Helpers/ShareContactQRDialog.swift | 91 +++++++++++++++++++ Meshtastic/Views/Nodes/NodeList.swift | 13 +++ 7 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 Meshtastic/Extensions/CoreData/NodeInfoEntityToNodeInfo.swift create mode 100644 Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 66649df5..7f4c9e0d 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -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 = ""; }; + 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = ""; }; 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = ""; }; 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = ""; }; 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Meshtastic/Extensions/CoreData/NodeInfoEntityToNodeInfo.swift b/Meshtastic/Extensions/CoreData/NodeInfoEntityToNodeInfo.swift new file mode 100644 index 00000000..c34f62a0 --- /dev/null +++ b/Meshtastic/Extensions/CoreData/NodeInfoEntityToNodeInfo.swift @@ -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.. 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).. Int64 { var adminPacket = AdminMessage() adminPacket.setOwner = config diff --git a/Meshtastic/Meshtastic.entitlements b/Meshtastic/Meshtastic.entitlements index abcef61d..0d2247ee 100644 --- a/Meshtastic/Meshtastic.entitlements +++ b/Meshtastic/Meshtastic.entitlements @@ -7,6 +7,7 @@ com.apple.developer.associated-domains applinks:meshtastic.org/e/* + applinks:meshtastic.org/v/* com.apple.developer.weatherkit diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 5c82c257..7ba8b701 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -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 { diff --git a/Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift b/Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift new file mode 100644 index 00000000..23b65ce6 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift @@ -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 diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index a17a19d0..168aa018 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -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(