mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Add shared contact functionality (WIP)
Feel free to hijack and make it not terrible
This commit is contained in:
parent
e64a01e72f
commit
05397db3a4
7 changed files with 247 additions and 2 deletions
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
91
Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift
Normal file
91
Meshtastic/Views/Nodes/Helpers/ShareContactQRDialog.swift
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue