diff --git a/Localizable.xcstrings b/Localizable.xcstrings index f7a00e2ae..7e0e39514 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -18798,6 +18798,15 @@ }, "Secondary" : { + }, + "Select a channel" : { + + }, + "Select a conversation" : { + + }, + "Select a conversation type" : { + }, "Select a Trace Route" : { @@ -18865,6 +18874,7 @@ } }, "select.menu.item" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 893857657..6c090d2fd 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -22,6 +22,10 @@ 25C49D902C471AEA0024FBD1 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C49D8F2C471AEA0024FBD1 /* Constants.swift */; }; 25F26B1E2C2F610D00C9CD9D /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB0C2C285F00007E03CA /* Logger.swift */; }; 25F26B1F2C2F611300C9CD9D /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD5BB152C28B1E4007E03CA /* AppData.swift */; }; + 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BD2C3F6D87008036E3 /* NavigationState.swift */; }; + 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5BF2C3F6DA6008036E3 /* Router.swift */; }; + 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5C12C3F6E4B008036E3 /* AppState.swift */; }; + 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F5D5D02C4375DF008036E3 /* RouterTests.swift */; }; 6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D825E612C34786C008DBEE4 /* CommonRegex.swift */; }; 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */; }; 6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEDA5592A957B8E00321D2E /* DetectionSensorLog.swift */; }; @@ -206,6 +210,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 25F5D5CB2C4375A8008036E3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DDC2E14C26CE248E0042C5E4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DDC2E15326CE248E0042C5E4; + remoteInfo = Meshtastic; + }; DDDE5A0129AF163E00490C6C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DDC2E14C26CE248E0042C5E4 /* Project object */; @@ -237,6 +248,11 @@ 2519268F2C3CB44900249DF5 /* ClientHistoryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientHistoryButton.swift; sourceTree = ""; }; 251926912C3CB52300249DF5 /* DeleteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteNodeButton.swift; sourceTree = ""; }; 25AECD4E2C2F723200862C8E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 25F5D5BD2C3F6D87008036E3 /* NavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationState.swift; sourceTree = ""; }; + 25F5D5BF2C3F6DA6008036E3 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; + 25F5D5C12C3F6E4B008036E3 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeshtasticTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 25F5D5D02C4375DF008036E3 /* RouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; 25C49D8F2C471AEA0024FBD1 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 6D825E612C34786C008DBEE4 /* CommonRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonRegex.swift; sourceTree = ""; }; 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshtasticAppDelegate.swift; sourceTree = ""; }; @@ -466,6 +482,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 25F5D5C42C4375A8008036E3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDC2E15126CE248E0042C5E4 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -502,6 +525,23 @@ path = Actions; sourceTree = ""; }; + 25F5D5BC2C3F6D7B008036E3 /* Router */ = { + isa = PBXGroup; + children = ( + 25F5D5BD2C3F6D87008036E3 /* NavigationState.swift */, + 25F5D5BF2C3F6DA6008036E3 /* Router.swift */, + ); + path = Router; + sourceTree = ""; + }; + 25F5D5C82C4375A8008036E3 /* MeshtasticTests */ = { + isa = PBXGroup; + children = ( + 25F5D5D02C4375DF008036E3 /* RouterTests.swift */, + ); + path = MeshtasticTests; + sourceTree = ""; + }; C9483F6B2773016700998F6B /* MapKitMap */ = { isa = PBXGroup; children = ( @@ -741,6 +781,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */, DDC2E15626CE248E0042C5E4 /* Meshtastic */, DDDE59F729AF163D00490C6C /* Widgets */, + 25F5D5C82C4375A8008036E3 /* MeshtasticTests */, DDC2E15526CE248E0042C5E4 /* Products */, DD8EDE9226F97A2B00A5A10B /* Frameworks */, ); @@ -752,6 +793,7 @@ children = ( DDC2E15426CE248E0042C5E4 /* Meshtastic.app */, DDDE59F429AF163D00490C6C /* WidgetsExtension.appex */, + 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */, ); name = Products; sourceTree = ""; @@ -759,6 +801,7 @@ DDC2E15626CE248E0042C5E4 /* Meshtastic */ = { isa = PBXGroup; children = ( + 25F5D5BC2C3F6D7B008036E3 /* Router */, DD7709392AA1ABA1007A8BF0 /* Tips */, DD90860A26F645B700DC5189 /* Meshtastic.entitlements */, DD8ED9C6289CE4A100B3B0AB /* Enums */, @@ -770,6 +813,7 @@ DDC2E18926CE24F70042C5E4 /* Resources */, DDC2E18726CE24E40042C5E4 /* Views */, DDC2E15726CE248E0042C5E4 /* MeshtasticApp.swift */, + 25F5D5C12C3F6E4B008036E3 /* AppState.swift */, DDC2E16526CE248F0042C5E4 /* Info.plist */, DDC2E15D26CE248F0042C5E4 /* Preview Content */, 6DA39D8D2A92DC52007E311C /* MeshtasticAppDelegate.swift */, @@ -965,6 +1009,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 25F5D5C62C4375A8008036E3 /* MeshtasticTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 25F5D5CF2C4375A8008036E3 /* Build configuration list for PBXNativeTarget "MeshtasticTests" */; + buildPhases = ( + 25F5D5C32C4375A8008036E3 /* Sources */, + 25F5D5C42C4375A8008036E3 /* Frameworks */, + 25F5D5C52C4375A8008036E3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 25F5D5CC2C4375A8008036E3 /* PBXTargetDependency */, + ); + name = MeshtasticTests; + productName = MeshtasticTests; + productReference = 25F5D5C72C4375A8008036E3 /* MeshtasticTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; DDC2E15326CE248E0042C5E4 /* Meshtastic */ = { isa = PBXNativeTarget; buildConfigurationList = DDC2E17E26CE248F0042C5E4 /* Build configuration list for PBXNativeTarget "Meshtastic" */; @@ -1017,9 +1079,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1420; + LastSwiftUpdateCheck = 1540; LastUpgradeCheck = 1540; TargetAttributes = { + 25F5D5C62C4375A8008036E3 = { + CreatedOnToolsVersion = 15.4; + TestTargetID = DDC2E15326CE248E0042C5E4; + }; DDC2E15326CE248E0042C5E4 = { CreatedOnToolsVersion = 12.5.1; LastSwiftMigration = 1340; @@ -1058,11 +1124,19 @@ targets = ( DDC2E15326CE248E0042C5E4 /* Meshtastic */, DDDE59F329AF163D00490C6C /* WidgetsExtension */, + 25F5D5C62C4375A8008036E3 /* MeshtasticTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 25F5D5C52C4375A8008036E3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDC2E15226CE248E0042C5E4 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1109,6 +1183,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 25F5D5C32C4375A8008036E3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DDC2E15026CE248E0042C5E4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1163,6 +1245,7 @@ DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */, DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */, DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */, + 25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */, DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */, DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */, @@ -1189,6 +1272,7 @@ DD3CC6BE28E4CD9800FA9159 /* BatteryGauge.swift in Sources */, DD6193772862F90F00E59241 /* CannedMessagesConfig.swift in Sources */, DD3619152B1EF9F900C41C8C /* LocationsHandler.swift in Sources */, + 25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */, DDDB444A29F8AA3A00EE2349 /* CLLocationCoordinate2D.swift in Sources */, 25C49D902C471AEA0024FBD1 /* Constants.swift in Sources */, DD41582628582E9B009B0E59 /* DeviceConfig.swift in Sources */, @@ -1268,6 +1352,7 @@ DDB6CCFB2AAF805100945AF6 /* NodeMapSwiftUI.swift in Sources */, DD73FD1128750779000852D6 /* PositionLog.swift in Sources */, DD15E4F52B8BFC8E00654F61 /* PaxCounterLog.swift in Sources */, + 25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */, DD3CC6C028E7A60700FA9159 /* MessagingEnums.swift in Sources */, DD97E96628EFD9820056DDA4 /* MeshtasticLogo.swift in Sources */, DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */, @@ -1307,6 +1392,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 25F5D5CC2C4375A8008036E3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DDC2E15326CE248E0042C5E4 /* Meshtastic */; + targetProxy = 25F5D5CB2C4375A8008036E3 /* PBXContainerItemProxy */; + }; DDDE5A0229AF163E00490C6C /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; @@ -1316,6 +1406,52 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 25F5D5CD2C4375A8008036E3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GCH7VS5Y9R; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Meshtastic.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Meshtastic"; + }; + name = Debug; + }; + 25F5D5CE2C4375A8008036E3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = GCH7VS5Y9R; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Meshtastic.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Meshtastic"; + }; + name = Release; + }; DDC2E17C26CE248F0042C5E4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1463,7 +1599,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.17; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1498,7 +1634,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.17; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1530,7 +1666,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.17; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1563,7 +1699,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.3.17; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1578,6 +1714,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 25F5D5CF2C4375A8008036E3 /* Build configuration list for PBXNativeTarget "MeshtasticTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 25F5D5CD2C4375A8008036E3 /* Debug */, + 25F5D5CE2C4375A8008036E3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DDC2E14F26CE248E0042C5E4 /* Build configuration list for PBXProject "Meshtastic" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Meshtastic.xcodeproj/xcshareddata/xcschemes/Meshtastic.xcscheme b/Meshtastic.xcodeproj/xcshareddata/xcschemes/Meshtastic.xcscheme index 68ab4aa31..9ef67c6de 100644 --- a/Meshtastic.xcodeproj/xcshareddata/xcschemes/Meshtastic.xcscheme +++ b/Meshtastic.xcodeproj/xcshareddata/xcschemes/Meshtastic.xcscheme @@ -33,7 +33,7 @@ skipped = "NO"> diff --git a/Meshtastic/AppState.swift b/Meshtastic/AppState.swift new file mode 100644 index 000000000..72f0d23a8 --- /dev/null +++ b/Meshtastic/AppState.swift @@ -0,0 +1,33 @@ +import Combine +import SwiftUI + +class AppState: ObservableObject { + @Published + var router: Router + + @Published + var unreadChannelMessages: Int + + @Published + var unreadDirectMessages: Int + + var totalUnreadMessages: Int { + unreadChannelMessages + unreadDirectMessages + } + + private var cancellables: Set = [] + + init(router: Router) { + self.router = router + self.unreadChannelMessages = 0 + self.unreadDirectMessages = 0 + + // Keep app icon badge count in sync with messages read status + $unreadChannelMessages.combineLatest($unreadDirectMessages) + .sink(receiveValue: { badgeCounts in + UNUserNotificationCenter.current() + .setBadgeCount(badgeCounts.0 + badgeCounts.1) + }) + .store(in: &cancellables) + } +} diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index cc8774995..e6a7f0cbd 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -12,10 +12,12 @@ import OSLog // --------------------------------------------------------------------------------------- class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate, ObservableObject { - var context: NSManagedObjectContext? + let appState: AppState + + let context: NSManagedObjectContext - static let shared = BLEManager() private var centralManager: CBCentralManager! + @Published var peripherals: [Peripheral] = [] @Published var connectedPeripheral: Peripheral! @Published var lastConnectionError: String @@ -24,7 +26,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate @Published var automaticallyReconnect: Bool = true @Published var mqttProxyConnected: Bool = false @Published var mqttError: String = "" - @StateObject var appState = AppState.shared public var minimumVersion = "2.0.0" public var connectedVersion: String public var isConnecting: Bool = false @@ -51,8 +52,15 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let LEGACY_LOGRADIO_UUID = CBUUID(string: "0x6C6FD238-78FA-436B-AACF-15C5BE1EF2E2") let LOGRADIO_UUID = CBUUID(string: "0x5a3d6e49-06e6-4423-9944-e9de8cdf9547") - // MARK: init BLEManager - override init() { + // MARK: init + + init( + appState: AppState, + context: NSManagedObjectContext + ) { + self.appState = appState + self.context = context + self.lastConnectionError = "" self.connectedVersion = "0.0.0" super.init() @@ -237,7 +245,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate subtitle: "\(peripheral.name ?? "unknown".localized)", content: e.localizedDescription, target: "bluetooth", - path: "meshtastic://bluetooth" + path: "meshtastic:///bluetooth" ) ] manager.schedule() @@ -257,7 +265,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate subtitle: "\(peripheral.name ?? "unknown".localized)", content: e.localizedDescription, target: "bluetooth", - path: "meshtastic://bluetooth" + path: "meshtastic:///bluetooth" ) ] manager.schedule() @@ -439,11 +447,11 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) success = true - let traceRoute = TraceRouteEntity(context: context!) + let traceRoute = TraceRouteEntity(context: context) let nodes = NodeInfoEntity.fetchRequest() nodes.predicate = NSPredicate(format: "num IN %@", [destNum, self.connectedPeripheral.num]) do { - let fetchedNodes = try context!.fetch(nodes) + let fetchedNodes = try context.fetch(nodes) let receivingNode = fetchedNodes.first(where: { $0.num == destNum }) let connectedNode = fetchedNodes.first(where: { $0.num == self.connectedPeripheral.num }) traceRoute.id = Int64(meshPacket.id) @@ -459,10 +467,10 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } } do { - try context!.save() + try context.save() Logger.data.info("💾 Saved TraceRoute sent to node: \(String(receivingNode?.user?.longName ?? "unknown".localized), privacy: .public)") } catch { - context!.rollback() + context.rollback() let nsError = error as NSError Logger.data.error("Error Updating Core Data BluetoothConfigEntity: \(nsError, privacy: .public)") } @@ -640,13 +648,10 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // Handle Any local only packets we get over BLE case .unknownApp: var nowKnown = false - guard let ctx = context else { - return - } // MyInfo from initial connection if decodedInfo.myInfo.isInitialized && decodedInfo.myInfo.myNodeNum > 0 { - let myInfo = myInfoPacket(myInfo: decodedInfo.myInfo, peripheralId: self.connectedPeripheral.id, context: ctx) + let myInfo = myInfoPacket(myInfo: decodedInfo.myInfo, peripheralId: self.connectedPeripheral.id, context: context) if myInfo != nil { UserDefaults.preferredPeripheralNum = Int(myInfo?.myNodeNum ?? 0) @@ -664,9 +669,9 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate do { disconnectPeripheral(reconnect: false) try container.restorePersistentStore(from: databasePath) - context?.refreshAllObjects() + context.refreshAllObjects() let request = MyInfoEntity.fetchRequest() - try context?.fetch(request) + try context.fetch(request) UserDefaults.preferredPeripheralNum = Int(myInfo?.myNodeNum ?? 0) connectTo(peripheral: peripheral) Logger.data.notice("🗂️ Restored Core data for /\(UserDefaults.preferredPeripheralNum, privacy: .public)") @@ -682,7 +687,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // NodeInfo if decodedInfo.nodeInfo.num > 0 { nowKnown = true - if let nodeInfo = nodeInfoPacket(nodeInfo: decodedInfo.nodeInfo, channel: decodedInfo.packet.channel, context: ctx) { + if let nodeInfo = nodeInfoPacket(nodeInfo: decodedInfo.nodeInfo, channel: decodedInfo.packet.channel, context: context) { if self.connectedPeripheral != nil && self.connectedPeripheral.num == nodeInfo.num { if nodeInfo.user != nil { connectedPeripheral.shortName = nodeInfo.user?.shortName ?? "?" @@ -694,17 +699,17 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // Channels if decodedInfo.channel.isInitialized && connectedPeripheral != nil { nowKnown = true - channelPacket(channel: decodedInfo.channel, fromNum: Int64(truncatingIfNeeded: connectedPeripheral.num), context: ctx) + channelPacket(channel: decodedInfo.channel, fromNum: Int64(truncatingIfNeeded: connectedPeripheral.num), context: context) } // Config if decodedInfo.config.isInitialized && !invalidVersion && connectedPeripheral != nil { nowKnown = true - localConfig(config: decodedInfo.config, context: ctx, nodeNum: Int64(truncatingIfNeeded: self.connectedPeripheral.num), nodeLongName: self.connectedPeripheral.longName) + localConfig(config: decodedInfo.config, context: context, nodeNum: Int64(truncatingIfNeeded: self.connectedPeripheral.num), nodeLongName: self.connectedPeripheral.longName) } // Module Config if decodedInfo.moduleConfig.isInitialized && !invalidVersion && self.connectedPeripheral?.num != 0 { nowKnown = true - moduleConfig(config: decodedInfo.moduleConfig, context: ctx, nodeNum: Int64(truncatingIfNeeded: self.connectedPeripheral?.num ?? 0), nodeLongName: self.connectedPeripheral.longName) + moduleConfig(config: decodedInfo.moduleConfig, context: context, nodeNum: Int64(truncatingIfNeeded: self.connectedPeripheral?.num ?? 0), nodeLongName: self.connectedPeripheral.longName) if decodedInfo.moduleConfig.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(decodedInfo.moduleConfig.cannedMessage) { if decodedInfo.moduleConfig.cannedMessage.enabled { _ = self.getCannedMessageModuleMessages(destNum: self.connectedPeripheral.num, wantResponse: true) @@ -714,7 +719,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // Device Metadata if decodedInfo.metadata.firmwareVersion.count > 0 && !invalidVersion { nowKnown = true - deviceMetadataPacket(metadata: decodedInfo.metadata, fromNum: connectedPeripheral.num, context: ctx) + deviceMetadataPacket(metadata: decodedInfo.metadata, fromNum: connectedPeripheral.num, context: context) connectedPeripheral.firmwareVersion = decodedInfo.metadata.firmwareVersion let lastDotIndex = decodedInfo.metadata.firmwareVersion.lastIndex(of: ".") if lastDotIndex == nil { @@ -724,7 +729,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let version = decodedInfo.metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: decodedInfo.metadata.firmwareVersion))] nowKnown = true connectedVersion = String(version.dropLast()) - appState.firmwareVersion = connectedVersion UserDefaults.firmwareVersion = connectedVersion } let supportedVersion = connectedVersion == "0.0.0" || self.minimumVersion.compare(connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(connectedVersion, options: .numeric) == .orderedSame @@ -737,22 +741,34 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate // Log any other unknownApp calls if !nowKnown { MeshLogger.log("🕸️ MESH PACKET received for Unknown App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")") } case .textMessageApp, .detectionSensorApp: - textMessageAppPacket(packet: decodedInfo.packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) + textMessageAppPacket( + packet: decodedInfo.packet, + wantRangeTestPackets: wantRangeTestPackets, + connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), + context: context, + appState: appState + ) case .remoteHardwareApp: MeshLogger.log("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")") case .positionApp: - upsertPositionPacket(packet: decodedInfo.packet, context: context!) + upsertPositionPacket(packet: decodedInfo.packet, context: context) case .waypointApp: - waypointPacket(packet: decodedInfo.packet, context: context!) + waypointPacket(packet: decodedInfo.packet, context: context) case .nodeinfoApp: - if !invalidVersion { upsertNodeInfoPacket(packet: decodedInfo.packet, context: context!) } + if !invalidVersion { upsertNodeInfoPacket(packet: decodedInfo.packet, context: context) } case .routingApp: - if !invalidVersion { routingPacket(packet: decodedInfo.packet, connectedNodeNum: self.connectedPeripheral.num, context: context!) } + if !invalidVersion { routingPacket(packet: decodedInfo.packet, connectedNodeNum: self.connectedPeripheral.num, context: context) } case .adminApp: - adminAppPacket(packet: decodedInfo.packet, context: context!) + adminAppPacket(packet: decodedInfo.packet, context: context) case .replyApp: MeshLogger.log("🕸️ MESH PACKET received for Reply App handling as a text message") - textMessageAppPacket(packet: decodedInfo.packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) + textMessageAppPacket( + packet: decodedInfo.packet, + wantRangeTestPackets: wantRangeTestPackets, + connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), + context: context, + appState: appState + ) case .ipTunnelApp: // MeshLogger.log("🕸️ MESH PACKET received for IP Tunnel App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")") MeshLogger.log("🕸️ MESH PACKET received for IP Tunnel App UNHANDLED UNHANDLED") @@ -761,18 +777,24 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate MeshLogger.log("🕸️ MESH PACKET received for Serial App UNHANDLED UNHANDLED") case .storeForwardApp: if wantStoreAndForwardPackets { - storeAndForwardPacket(packet: decodedInfo.packet, connectedNodeNum: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) + storeAndForwardPacket(packet: decodedInfo.packet, connectedNodeNum: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context) } else { MeshLogger.log("🕸️ MESH PACKET received for Store and Forward App - Store and Forward is disabled.") } case .rangeTestApp: if wantRangeTestPackets { - textMessageAppPacket(packet: decodedInfo.packet, wantRangeTestPackets: true, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) + textMessageAppPacket( + packet: decodedInfo.packet, + wantRangeTestPackets: true, + connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), + context: context, + appState: appState + ) } else { MeshLogger.log("🕸️ MESH PACKET received for Range Test App Range testing is disabled.") } case .telemetryApp: - if !invalidVersion { telemetryPacket(packet: decodedInfo.packet, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context!) } + if !invalidVersion { telemetryPacket(packet: decodedInfo.packet, connectedNode: (self.connectedPeripheral != nil ? connectedPeripheral.num : 0), context: context) } case .textMessageCompressedApp: // MeshLogger.log("🕸️ MESH PACKET received for Text Message Compressed App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")") MeshLogger.log("🕸️ MESH PACKET received for Text Message Compressed App UNHANDLED") @@ -793,7 +815,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate MeshLogger.log("🕸️ MESH PACKET received for Audio App UNHANDLED UNHANDLED") case .tracerouteApp: if let routingMessage = try? RouteDiscovery(serializedData: decodedInfo.packet.decoded.payload) { - let traceRoute = getTraceRoute(id: Int64(decodedInfo.packet.decoded.requestID), context: context!) + let traceRoute = getTraceRoute(id: Int64(decodedInfo.packet.decoded.requestID), context: context) traceRoute?.response = true traceRoute?.route = routingMessage.route if routingMessage.route.count == 0 { @@ -804,11 +826,11 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate var routeString = "You --> " var hopNodes: [TraceRouteHopEntity] = [] for node in routingMessage.route { - var hopNode = getNodeInfo(id: Int64(node), context: context!) + var hopNode = getNodeInfo(id: Int64(node), context: context) if hopNode == nil && hopNode?.num ?? 0 > 0 { - hopNode = createNodeInfo(num: Int64(node), context: context!) + hopNode = createNodeInfo(num: Int64(node), context: context) } - let traceRouteHop = TraceRouteHopEntity(context: context!) + let traceRouteHop = TraceRouteHopEntity(context: context) traceRouteHop.time = Date() if hopNode?.hasPositions ?? false { traceRoute?.hasPositions = true @@ -836,10 +858,10 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate traceRoute?.routeText = routeString traceRoute?.hops = NSOrderedSet(array: hopNodes) do { - try context!.save() + try context.save() Logger.data.info("💾 Saved Trace Route") } catch { - context!.rollback() + context.rollback() let nsError = error as NSError Logger.data.error("Error Updating Core Data TraceRouteHOp: \(nsError, privacy: .public)") } @@ -853,7 +875,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate MeshLogger.log("🕸️ MESH PACKET received for Neighbor Info App UNHANDLED \(neighborInfo)") } case .paxcounterApp: - paxCounterPacket(packet: decodedInfo.packet, context: context!) + paxCounterPacket(packet: decodedInfo.packet, context: context) case .mapReportApp: MeshLogger.log("🕸️ MESH PACKET received Map Report App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure")") case .UNRECOGNIZED: @@ -880,7 +902,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(connectedPeripheral.num)) do { - let fetchedNodeInfo = try context?.fetch(fetchNodeInfoRequest) ?? [] + let fetchedNodeInfo = try context.fetch(fetchNodeInfoRequest) ?? [] if fetchedNodeInfo.count == 1 { // Subscribe to Mqtt Client Proxy if enabled if fetchedNodeInfo[0].mqttConfig != nil && fetchedNodeInfo[0].mqttConfig?.enabled ?? false && fetchedNodeInfo[0].mqttConfig?.proxyToClientEnabled ?? false { @@ -891,12 +913,8 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate } } // Set initial unread message badge states - let appState = AppState.shared appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages ?? 0 appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages ?? 0 - // appState.connectedNode = fetchedNodeInfo[0] - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages - } if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].rangeTestConfig?.enabled == true { wantRangeTestPackets = true @@ -960,7 +978,6 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate success = false } else { - guard let context else { return false } let fromUserNum: Int64 = self.connectedPeripheral.num let messageUsers = UserEntity.fetchRequest() @@ -1081,7 +1098,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) success = true - let wayPointEntity = getWaypoint(id: Int64(waypoint.id), context: context!) + let wayPointEntity = getWaypoint(id: Int64(waypoint.id), context: context) wayPointEntity.id = Int64(waypoint.id) wayPointEntity.name = waypoint.name.count >= 1 ? waypoint.name : "Dropped Pin" wayPointEntity.longDescription = waypoint.description_p @@ -1104,10 +1121,10 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate wayPointEntity.lastUpdated = Date() } do { - try context!.save() + try context.save() Logger.data.info("💾 Updated Waypoint from Waypoint App Packet From: \(fromNodeNum.toHex(), privacy: .public)") } catch { - context!.rollback() + context.rollback() let nsError = error as NSError Logger.data.error("Error Saving NodeInfoEntity from WAYPOINT_APP \(nsError, privacy: .public)") } @@ -1115,6 +1132,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return success } + @MainActor public func getPositionFromPhoneGPS(destNum: Int64) -> Position? { var positionPacket = Position() if #available(iOS 17.0, macOS 14.0, *) { @@ -1160,6 +1178,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return positionPacket } + @MainActor public func setFixedPosition(fromUser: UserEntity, channel: Int32) -> Bool { var adminPacket = AdminMessage() guard let positionPacket = getPositionFromPhoneGPS(destNum: fromUser.num) else { @@ -1214,6 +1233,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return false } + @MainActor public func sendPosition(channel: Int32, destNum: Int64, wantResponse: Bool) -> Bool { let fromNodeNum = connectedPeripheral.num guard let positionPacket = getPositionFromPhoneGPS(destNum: destNum) else { @@ -1254,6 +1274,8 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate return false } } + + @MainActor @objc func positionTimerFired(timer: Timer) { // Check for connected node if connectedPeripheral != nil { @@ -1501,7 +1523,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let fetchMyInfoRequest = MyInfoEntity.fetchRequest() fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedPeripheral.num)) do { - let fetchedMyInfo = try context?.fetch(fetchMyInfoRequest) ?? [] + let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) ?? [] if fetchedMyInfo.count == 1 { i = Int32(fetchedMyInfo[0].channels?.count ?? -1) myInfo = fetchedMyInfo[0] @@ -1653,12 +1675,12 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate if connectedPeripheral?.peripheral.state ?? CBPeripheralState.disconnected == CBPeripheralState.connected { do { connectedPeripheral.peripheral.writeValue(binaryData, for: TORADIO_characteristic, type: .withResponse) - context!.delete(node.user!) - context!.delete(node) - try context!.save() + context.delete(node.user!) + context.delete(node) + try context.save() return true } catch { - context!.rollback() + context.rollback() let nsError = error as NSError Logger.data.error("🚫 Error deleting node from core data: \(nsError)") } @@ -1767,7 +1789,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Bluetooth Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertBluetoothConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertBluetoothConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -1795,7 +1817,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate meshPacket.decoded = dataMessage let messageDescription = "🛟 Saved Device Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertDeviceConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertDeviceConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } return 0 @@ -1822,7 +1844,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate meshPacket.decoded = dataMessage let messageDescription = "🛟 Saved Display Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertDisplayConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertDisplayConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } return 0 @@ -1849,7 +1871,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved LoRa Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertLoRaConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertLoRaConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -1880,7 +1902,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Position Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertPositionConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertPositionConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -1911,7 +1933,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Power Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertPowerConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertPowerConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -1942,7 +1964,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Network Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertNetworkConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertNetworkConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -1972,7 +1994,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Ambient Lighting Module Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertAmbientLightingModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertAmbientLightingModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -2002,7 +2024,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Canned Message Module Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertCannedMessagesModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertCannedMessagesModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -2063,7 +2085,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Detection Sensor Module Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertDetectionSensorModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertDetectionSensorModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } return 0 @@ -2092,7 +2114,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved External Notification Module Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertExternalNotificationModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertExternalNotificationModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } return 0 @@ -2121,7 +2143,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved PAX Counter Module Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertPaxCounterModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertPaxCounterModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -2151,7 +2173,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved RTTTL Ringtone Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertRtttlConfigPacket(ringtone: ringtone, nodeNum: toUser.num, context: context!) + upsertRtttlConfigPacket(ringtone: ringtone, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -2182,7 +2204,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved MQTT Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertMqttModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertMqttModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } return 0 @@ -2211,7 +2233,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Range Test Module Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertRangeTestModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertRangeTestModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } @@ -2241,7 +2263,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Serial Module Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertSerialModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertSerialModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } return 0 @@ -2270,7 +2292,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "🛟 Saved Store & Forward Module Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertStoreForwardModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertStoreForwardModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } return 0 @@ -2299,7 +2321,7 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate let messageDescription = "Saved Telemetry Module Config for \(toUser.longName ?? "unknown".localized)" if sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) { - upsertTelemetryModuleConfigPacket(config: config, nodeNum: toUser.num, context: context!) + upsertTelemetryModuleConfigPacket(config: config, nodeNum: toUser.num, context: context) return Int64(meshPacket.id) } return 0 @@ -3058,16 +3080,29 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate MeshLogger.log("📮 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())") case .routerTextDirect: MeshLogger.log("💬 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())") - textMessageAppPacket(packet: packet, wantRangeTestPackets: false, connectedNode: connectedNodeNum, storeForward: true, context: context) + textMessageAppPacket( + packet: packet, + wantRangeTestPackets: false, + connectedNode: connectedNodeNum, + storeForward: true, + context: context, + appState: appState + ) case .routerTextBroadcast: MeshLogger.log("✉️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())") - textMessageAppPacket(packet: packet, wantRangeTestPackets: false, connectedNode: connectedNodeNum, storeForward: true, context: context) + textMessageAppPacket( + packet: packet, + wantRangeTestPackets: false, + connectedNode: connectedNodeNum, + storeForward: true, + context: context, + appState: appState + ) } } } public func tryClearExistingChannels() { - guard let context else { return } // Before we get started delete the existing channels from the myNodeInfo let fetchMyInfoRequest = MyInfoEntity.fetchRequest() fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(connectedPeripheral.num)) diff --git a/Meshtastic/Helpers/LocalNotificationManager.swift b/Meshtastic/Helpers/LocalNotificationManager.swift index d9ff996a4..3e41c8f39 100644 --- a/Meshtastic/Helpers/LocalNotificationManager.swift +++ b/Meshtastic/Helpers/LocalNotificationManager.swift @@ -40,18 +40,19 @@ class LocalNotificationManager { content.interruptionLevel = .timeSensitive if notification.target != nil { - content.userInfo["target"] = notification.target + content.userInfo["target"] = notification.target } if notification.path != nil { - content.userInfo["path"] = notification.path + content.userInfo["path"] = notification.path } let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: trigger) UNUserNotificationCenter.current().add(request) { error in - - guard error == nil else { return } + if let error { + Logger.services.error("Error Scheduling Notification: \(error.localizedDescription)") + } } } } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index cd3b7701d..a789ad3b3 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -714,7 +714,7 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")", content: "Time to charge your radio, there is \(telemetry.batteryLevel)% battery remaining.", target: "nodes", - path: "meshtastic://nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" + path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)" ) ] manager.schedule() @@ -748,7 +748,14 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage } } -func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connectedNode: Int64, storeForward: Bool = false, context: NSManagedObjectContext) { +func textMessageAppPacket( + packet: MeshPacket, + wantRangeTestPackets: Bool, + connectedNode: Int64, + storeForward: Bool = false, + context: NSManagedObjectContext, + appState: AppState +) { var messageText = String(bytes: packet.decoded.payload, encoding: .utf8) let rangeRef = Reference(Int.self) @@ -832,12 +839,10 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications { return } - let appState = AppState.shared if newMessage.fromUser != nil && newMessage.toUser != nil { // Set Unread Message Indicators if packet.to == connectedNode { appState.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0 - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages } if !(newMessage.fromUser?.mute ?? false) { // Create an iOS Notification for the received DM message and schedule it immediately @@ -849,7 +854,7 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic://messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)" + path: "meshtastic:///messages?userNum=\(newMessage.fromUser?.num ?? 0)&messageId=\(newMessage.messageId)" ) ] manager.schedule() @@ -864,7 +869,6 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec let fetchedMyInfo = try context.fetch(fetchMyInfoRequest) if !fetchedMyInfo.isEmpty { appState.unreadChannelMessages = fetchedMyInfo[0].unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] { if channel.index == newMessage.channel { @@ -880,7 +884,7 @@ func textMessageAppPacket(packet: MeshPacket, wantRangeTestPackets: Bool, connec subtitle: "AKA \(newMessage.fromUser?.shortName ?? "?")", content: messageText!, target: "messages", - path: "meshtastic://messages?channel=\(newMessage.channel)&messageId=\(newMessage.messageId)") + path: "meshtastic:///messages?channelId=\(newMessage.channel)&messageId=\(newMessage.messageId)") ] manager.schedule() Logger.services.debug("iOS Notification Scheduled for text message from \(newMessage.fromUser?.longName ?? "unknown".localized)") @@ -945,10 +949,10 @@ func waypointPacket (packet: MeshPacket, context: NSManagedObjectContext) { subtitle: "\(icon) \(waypoint.name ?? "Dropped Pin")", content: "\(waypoint.longDescription ?? "\(latitude), \(longitude)")", target: "map", - path: "meshtastic://map?waypontid=\(waypoint.id)" + path: "meshtastic:///map?waypointid=\(waypoint.id)" ) ] - Logger.data.debug("meshtastic://map?waypontid=\(waypoint.id)") + Logger.data.debug("meshtastic:///map?waypointid=\(waypoint.id)") manager.schedule() } catch { context.rollback() diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 578f16d41..c1bcf91a6 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -11,22 +11,48 @@ import TipKit @main struct MeshtasticAppleApp: App { - @UIApplicationDelegateAdaptor(MeshtasticAppDelegate.self) var appDelegate - let persistenceController = PersistenceController.shared - @ObservedObject private var bleManager: BLEManager = BLEManager.shared + @UIApplicationDelegateAdaptor(MeshtasticAppDelegate.self) + private var appDelegate - @Environment(\.scenePhase) var scenePhase + @ObservedObject + var appState: AppState + + @ObservedObject + private var bleManager: BLEManager + private let persistenceController: PersistenceController + + @Environment(\.scenePhase) var scenePhase @State var saveChannels = false @State var incomingUrl: URL? @State var channelSettings: String? @State var addChannels = false - @StateObject var appState = AppState.shared + + init() { + let persistenceController = PersistenceController.shared + let appState = AppState( + router: Router() + ) + self._appState = ObservedObject(wrappedValue: appState) + + self.bleManager = BLEManager( + appState: appState, + context: persistenceController.container.viewContext + ) + self.persistenceController = persistenceController + + // Wire up router + self.appDelegate.router = appState.router + } var body: some Scene { WindowGroup { - ContentView() + ContentView( + appState: appState, + router: appState.router + ) .environment(\.managedObjectContext, persistenceController.container.viewContext) + .environmentObject(appState) .environmentObject(bleManager) .sheet(isPresented: $saveChannels) { SaveChannelQRCode(channelSetLink: channelSettings ?? "Empty Channel URL", addChannels: addChannels, bleManager: bleManager) @@ -34,14 +60,13 @@ struct MeshtasticAppleApp: App { .presentationDragIndicator(.visible) } .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in - Logger.mesh.debug("URL received \(userActivity)") self.incomingUrl = userActivity.webpageURL if (self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/#")) != nil { if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") { self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false - if ((self.incomingUrl?.absoluteString.lowercased().contains("?")) != nil) { + if (self.incomingUrl?.absoluteString.lowercased().contains("?")) != nil { guard let cs = components.last!.components(separatedBy: "?").first else { return } @@ -83,15 +108,8 @@ struct MeshtasticAppleApp: App { } self.saveChannels = true Logger.mesh.debug("User wants to open a Channel Settings URL: \(self.incomingUrl?.absoluteString ?? "No QR Code Link")") - } else if url.absoluteString.lowercased().contains("meshtastic://") { - appState.navigationPath = url.absoluteString - let path = appState.navigationPath ?? "" - if path.starts(with: "meshtastic://map") { - AppState.shared.tabSelection = Tab.map - } else if path.starts(with: "meshtastic://nodes") { - AppState.shared.tabSelection = Tab.nodes - } - + } else if url.absoluteString.lowercased().contains("meshtastic:///") { + appState.router.route(url: url) } else { saveChannels = false Logger.mesh.debug("User wants to import a MBTILES offline map file: \(self.incomingUrl?.absoluteString ?? "No Tiles link")") @@ -179,13 +197,3 @@ struct MeshtasticAppleApp: App { } } } - -class AppState: ObservableObject { - static let shared = AppState() - - @Published var tabSelection: Tab = .ble - @Published var unreadDirectMessages: Int = 0 - @Published var unreadChannelMessages: Int = 0 - @Published var firmwareVersion: String = "0.0.0" - @Published var navigationPath: String? -} diff --git a/Meshtastic/MeshtasticAppDelegate.swift b/Meshtastic/MeshtasticAppDelegate.swift index 71f01d81c..06d290e99 100644 --- a/Meshtastic/MeshtasticAppDelegate.swift +++ b/Meshtastic/MeshtasticAppDelegate.swift @@ -9,6 +9,9 @@ import SwiftUI import OSLog class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, ObservableObject { + + var router: Router? + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { Logger.services.info("🚀 [App] Meshtstic Apple App launched!") // Default User Default Values @@ -28,23 +31,30 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat return true } // Lets us show the notification in the app in the foreground - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { completionHandler([.list, .banner, .sound]) } + // This method is called when a user clicks on the notification - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { let userInfo = response.notification.request.content.userInfo - let targetValue = userInfo["target"] as? String - let deepLink = userInfo["path"] as? String - - AppState.shared.navigationPath = deepLink - if targetValue == "map" { - AppState.shared.tabSelection = Tab.map - } else if targetValue == "messages" { - AppState.shared.tabSelection = Tab.messages - } else if targetValue == "nodes" { - AppState.shared.tabSelection = Tab.nodes + if let targetValue = userInfo["target"] as? String, + let deepLink = userInfo["path"] as? String, + let url = URL(string: deepLink) { + Logger.services.info("userNotificationCenter didReceiveResponse \(targetValue) \(deepLink)") + router?.route(url: url) + } else { + Logger.services.error("Failed to handle notification response: \(userInfo)") } + completionHandler() } } diff --git a/Meshtastic/Persistence/UpdateCoreData.swift b/Meshtastic/Persistence/UpdateCoreData.swift index c612dd4ea..ab3cdf472 100644 --- a/Meshtastic/Persistence/UpdateCoreData.swift +++ b/Meshtastic/Persistence/UpdateCoreData.swift @@ -188,7 +188,7 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) subtitle: "\(newUser.longName ?? "unknown".localized)", content: "New Node has been discovered", target: "nodes", - path: "meshtastic://nodes?nodenum=\(newUser.num)" + path: "meshtastic:///nodes?nodenum=\(newUser.num)" ) ] manager.schedule() diff --git a/Meshtastic/Router/NavigationState.swift b/Meshtastic/Router/NavigationState.swift new file mode 100644 index 000000000..b33fc48a3 --- /dev/null +++ b/Meshtastic/Router/NavigationState.swift @@ -0,0 +1,106 @@ +import Foundation + +// MARK: Messages + +enum MessagesNavigationState: Hashable { + case channels( + channelId: Int32? = nil, + messageId: Int64? = nil + ) + case directMessages( + userNum: Int64? = nil, + messageId: Int64? = nil + ) +} + +// MARK: Map + +enum MapNavigationState: Hashable { + case selectedNode(Int64) + case waypoint(Int64) +} + +// MARK: Settings + +enum SettingsNavigationState: String { + case about + case appSettings + case routes + case routeRecorder + case lora + case channels + case shareQRCode + case user + case bluetooth + case device + case display + case network + case position + case power + case ambientLighting + case cannedMessages + case detectionSensor + case externalNotification + case mqtt + case rangeTest + case paxCounter + case ringtone + case serial + case storeAndForward + case telemetry + case meshLog + case debugLogs + case appFiles + case firmwareUpdates +} + +enum NavigationState: Hashable { + case messages(MessagesNavigationState? = nil) + case bluetooth + case nodes(selectedNodeNum: Int64? = nil) + case map(MapNavigationState? = nil) + case settings(SettingsNavigationState? = nil) +} + +// MARK: Tab Bar + +extension NavigationState { + enum Tab: String, Hashable { + case messages + case bluetooth + case nodes + case map + case settings + } + + var tab: Tab { + get { + switch self { + case .messages: + .messages + case .bluetooth: + .bluetooth + case .nodes: + .nodes + case .map: + .map + case .settings: + .settings + } + } + set { + self = switch newValue { + case .messages: + .messages() + case .bluetooth: + .bluetooth + case .nodes: + .nodes() + case .map: + .map() + case .settings: + .settings() + } + } + } +} diff --git a/Meshtastic/Router/Router.swift b/Meshtastic/Router/Router.swift new file mode 100644 index 000000000..51803ed45 --- /dev/null +++ b/Meshtastic/Router/Router.swift @@ -0,0 +1,117 @@ +import Combine +import CoreData +import OSLog +import SwiftUI + +@MainActor +class Router: ObservableObject { + + @Published + var navigationState: NavigationState + + private var cancellables: Set = [] + + init( + navigationState: NavigationState = .bluetooth + ) { + self.navigationState = navigationState + + $navigationState.sink { destination in + Logger.services.info("🛣 Routed to \(String(describing: destination), privacy: .public)") + }.store(in: &cancellables) + } + + func route(to destination: NavigationState) { + navigationState = destination + } + + func route(url: URL) { + guard url.scheme == "meshtastic" else { + Logger.services.error("🛣 Received routing URL \(url, privacy: .public) with invalid scheme. Ignoring route.") + return + } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + Logger.services.error("🛣 Received routing URL \(url, privacy: .public) with invalid host path. Ignoring route.") + return + } + + if components.path == "/messages" { + routeMessages(components) + } else if components.path == "/bluetooth" { + route(to: .bluetooth) + } else if components.path == "/nodes" { + routeNodes(components) + } else if components.path == "/map" { + routeMap(components) + } else if components.path.hasPrefix("/settings") { + routeSettings(components) + } else { + Logger.services.warning("Failed to route url: \(url)") + } + } + + // MARK: Routing Helpers + + private func routeMessages( + _ components: URLComponents + ) { + let channelId = components.queryItems? + .first(where: { $0.name == "channelId" })? + .value + .flatMap(Int32.init) + let userNum = components.queryItems? + .first(where: { $0.name == "userNum" })? + .value + .flatMap(Int64.init) + let messageId = components.queryItems? + .first(where: { $0.name == "messageId" })? + .value + .flatMap(Int64.init) + + let state: MessagesNavigationState? = if let channelId { + .channels(channelId: channelId, messageId: messageId) + } else if let userNum { + .directMessages(userNum: userNum, messageId: messageId) + } else { + nil + } + route(to: .messages(state)) + } + + private func routeNodes(_ components: URLComponents) { + let nodeId = components.queryItems? + .first(where: { $0.name == "nodenum" })? + .value + .flatMap(Int64.init) + route(to: .nodes(selectedNodeNum: nodeId)) + } + + private func routeMap(_ components: URLComponents) { + let nodeId = components.queryItems? + .first(where: { $0.name == "nodenum" })? + .value + .flatMap(Int64.init) + let waypointId = components.queryItems? + .first(where: { $0.name == "waypointId" })? + .value + .flatMap(Int64.init) + if let nodeId { + route(to: .map(.selectedNode(nodeId))) + } else if let waypointId { + route(to: .map(.waypoint(waypointId))) + } else { + route(to: .map()) + } + } + + private func routeSettings(_ components: URLComponents) { + let settingFromPath = components.path + .split(separator: "/") + .dropFirst() + .first + .flatMap(String.init) + .flatMap(SettingsNavigationState.init(rawValue:)) + + route(to: .settings(settingFromPath)) + } +} diff --git a/Meshtastic/Views/Bluetooth/Connect.swift b/Meshtastic/Views/Bluetooth/Connect.swift index c3745ebdc..0048e4309 100644 --- a/Meshtastic/Views/Bluetooth/Connect.swift +++ b/Meshtastic/Views/Bluetooth/Connect.swift @@ -323,11 +323,6 @@ struct Connect: View { } } } - .onAppear(perform: { - if self.bleManager.context == nil { - self.bleManager.context = context - } - }) } #if canImport(ActivityKit) func startNodeActivity() { diff --git a/Meshtastic/Views/ContentView.swift b/Meshtastic/Views/ContentView.swift index 46f7f903d..b109a3188 100644 --- a/Meshtastic/Views/ContentView.swift +++ b/Meshtastic/Views/ContentView.swift @@ -6,75 +6,68 @@ import SwiftUI @available(iOS 17.0, *) struct ContentView: View { + @ObservedObject + var appState: AppState + + @ObservedObject + var router: Router - @StateObject var appState = AppState.shared var body: some View { - TabView(selection: $appState.tabSelection) { - Messages() - .tabItem { - Label("messages", systemImage: "message") - } - .tag(Tab.contacts) - .badge(appState.unreadDirectMessages + appState.unreadChannelMessages) + TabView(selection: Binding( + get: { + appState.router.navigationState.tab + }, + set: { newValue in + appState.router.navigationState.tab = newValue + } + )) { + Messages( + router: appState.router, + unreadChannelMessages: $appState.unreadChannelMessages, + unreadDirectMessages: $appState.unreadDirectMessages + ) + .tabItem { + Label("messages", systemImage: "message") + } + .tag(NavigationState.Tab.messages) + .badge(appState.totalUnreadMessages) + Connect() .tabItem { Label("bluetooth", systemImage: "antenna.radiowaves.left.and.right") } - .tag(Tab.ble) - NodeList() - .tabItem { - Label("nodes", systemImage: "flipphone") - } - .tag(Tab.nodes) - if #available(iOS 17.0, macOS 14.0, *) { - if UserDefaults.mapUseLegacy { - NodeMap() - .tabItem { - Label("map", systemImage: "map") - } - .tag(Tab.map) - } else { - MeshMap() - .tabItem { - Label("map", systemImage: "map") - } - .tag(Tab.map) - } + .tag(NavigationState.Tab.bluetooth) + + NodeList( + router: appState.router + ) + .tabItem { + Label("nodes", systemImage: "flipphone") + } + .tag(NavigationState.Tab.nodes) + + if #available(iOS 17.0, macOS 14.0, *), !UserDefaults.mapUseLegacy { + MeshMap(router: appState.router) + .tabItem { + Label("map", systemImage: "map") + } + .tag(NavigationState.Tab.map) } else { - NodeMap() + NodeMap(router: appState.router) .tabItem { Label("map", systemImage: "map") } - .tag(Tab.map) + .tag(NavigationState.Tab.map) } - Settings() - .tabItem { - Label("settings", systemImage: "gear") - .font(.title) - } - .tag(Tab.settings) + + Settings( + router: appState.router + ) + .tabItem { + Label("settings", systemImage: "gear") + .font(.title) + } + .tag(NavigationState.Tab.settings) } } } -// #Preview { -// if #available(iOS 17.0, *) { -// // ContentView(deepLinkManager: .init()) -// } else { -// // Fallback on earlier versions -// } -// } - -// struct ContentView_Previews: PreviewProvider { -// static var previews: some View { -// ContentView() -// } -// } - -enum Tab: Hashable { - case contacts - case messages - case map - case ble - case nodes - case settings -} diff --git a/Meshtastic/Views/MapKitMap/WaypointFormMapKit.swift b/Meshtastic/Views/MapKitMap/WaypointFormMapKit.swift index 8217af2d2..d5fd9c013 100644 --- a/Meshtastic/Views/MapKitMap/WaypointFormMapKit.swift +++ b/Meshtastic/Views/MapKitMap/WaypointFormMapKit.swift @@ -122,7 +122,7 @@ struct WaypointFormMapKit: View { // Loading a waypoint from edit if coordinate.waypointId > 0 { newWaypoint.id = UInt32(coordinate.waypointId) - let waypoint = getWaypoint(id: Int64(coordinate.waypointId), context: bleManager.context!) + let waypoint = getWaypoint(id: Int64(coordinate.waypointId), context: bleManager.context) newWaypoint.latitudeI = waypoint.latitudeI newWaypoint.longitudeI = waypoint.longitudeI } else { @@ -179,12 +179,12 @@ struct WaypointFormMapKit: View { Menu { Button("For me", action: { - let waypoint = getWaypoint(id: Int64(coordinate.waypointId), context: bleManager.context!) - bleManager.context!.delete(waypoint) + let waypoint = getWaypoint(id: Int64(coordinate.waypointId), context: bleManager.context) + bleManager.context.delete(waypoint) do { - try bleManager.context!.save() + try bleManager.context.save() } catch { - bleManager.context!.rollback() + bleManager.context.rollback() } dismiss() }) Button("For everyone", action: { @@ -230,7 +230,7 @@ struct WaypointFormMapKit: View { } .onAppear { if coordinate.waypointId > 0 { - let waypoint = getWaypoint(id: Int64(coordinate.waypointId), context: bleManager.context!) + let waypoint = getWaypoint(id: Int64(coordinate.waypointId), context: bleManager.context) name = waypoint.name ?? "Dropped Pin" description = waypoint.longDescription ?? "" icon = String(UnicodeScalar(Int(waypoint.icon)) ?? "📍") diff --git a/Meshtastic/Views/Messages/ChannelList.swift b/Meshtastic/Views/Messages/ChannelList.swift index 30738fa35..a4fd86bf6 100644 --- a/Meshtastic/Views/Messages/ChannelList.swift +++ b/Meshtastic/Views/Messages/ChannelList.swift @@ -11,13 +11,15 @@ import OSLog struct ChannelList: View { - @StateObject var appState = AppState.shared @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @State var node: NodeInfoEntity? + @Binding + var node: NodeInfoEntity? + + @Binding + var channelSelection: ChannelEntity? - @State private var channelSelection: ChannelEntity? // Nothing selected by default. @State private var isPresentingDeleteChannelMessagesConfirm: Bool = false @State private var isPresentingTraceRouteSentAlert = false @@ -25,14 +27,14 @@ struct ChannelList: View { var restrictedChannels = ["gpio", "mqtt", "serial"] @ViewBuilder - private func makeNavigationLink( + private func makeChannelRow( myInfo: MyInfoEntity, channel: ChannelEntity ) -> some View { let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current) let dateFormatString = (localeDateFormat ?? "MM/dd/YY") - NavigationLink(destination: ChannelMessageList(myInfo: myInfo, channel: channel)) { + NavigationLink(value: channel) { let mostRecent = channel.allPrivateMessages.last(where: { $0.channel == channel.index }) let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 @@ -102,57 +104,50 @@ struct ChannelList: View { VStack { // Display Contacts for the rest of the non admin channels if let node, let myInfo = node.myInfo, let channels = myInfo.channels?.array as? [ChannelEntity] { - List(channels, id: \.self, selection: $channelSelection) { (channel: ChannelEntity) in - if !restrictedChannels.contains(channel.name?.lowercased() ?? "") { - makeNavigationLink(myInfo: myInfo, channel: channel) - .frame(height: 62) - .contextMenu { - if channel.allPrivateMessages.count > 0 { - Button(role: .destructive) { - isPresentingDeleteChannelMessagesConfirm = true - channelSelection = channel - } label: { - Label("Delete Messages", systemImage: "trash") + List(selection: $channelSelection) { + ForEach(channels) { (channel: ChannelEntity) in + if !restrictedChannels.contains(channel.name?.lowercased() ?? "") { + makeChannelRow(myInfo: myInfo, channel: channel) + .frame(height: 62) + .contextMenu { + if channel.allPrivateMessages.count > 0 { + Button(role: .destructive) { + isPresentingDeleteChannelMessagesConfirm = true + channelSelection = channel + } label: { + Label("Delete Messages", systemImage: "trash") + } } - } - Button { - channel.mute = !channel.mute - - do { - let adminMessageId = bleManager.saveChannel(channel: channel.protoBuf, fromUser: node.user!, toUser: node.user!) - if adminMessageId > 0 { - context.refresh(channel, mergeChanges: true) + Button { + channel.mute = !channel.mute + do { + let adminMessageId = bleManager.saveChannel(channel: channel.protoBuf, fromUser: node.user!, toUser: node.user!) + if adminMessageId > 0 { + context.refresh(channel, mergeChanges: true) + } + try context.save() + } catch { + context.rollback() + Logger.data.error("💥 Save Channel Mute Error") } - - try context.save() - - } catch { - context.rollback() - Logger.data.error("💥 Save Channel Mute Error") + } label: { + Label(channel.mute ? "Show Alerts" : "Hide Alerts", systemImage: channel.mute ? "bell" : "bell.slash") } - } label: { - Label(channel.mute ? "Show Alerts" : "Hide Alerts", systemImage: channel.mute ? "bell" : "bell.slash") } - } - .confirmationDialog( - "This conversation will be deleted.", - isPresented: $isPresentingDeleteChannelMessagesConfirm, - titleVisibility: .visible - ) { - Button(role: .destructive) { - deleteChannelMessages(channel: channelSelection!, context: context) - context.refresh(myInfo, mergeChanges: true) - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages - channelSelection = nil - } label: { - Text("delete") - } - } - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context + .confirmationDialog( + "This conversation will be deleted.", + isPresented: $isPresentingDeleteChannelMessagesConfirm, + titleVisibility: .visible + ) { + Button(role: .destructive) { + deleteChannelMessages(channel: channelSelection!, context: context) + context.refresh(myInfo, mergeChanges: true) + channelSelection = nil + } label: { + Text("delete") + } } - } + } } } .padding([.top, .bottom]) diff --git a/Meshtastic/Views/Messages/ChannelMessageList.swift b/Meshtastic/Views/Messages/ChannelMessageList.swift index 882dad079..b996e7905 100644 --- a/Meshtastic/Views/Messages/ChannelMessageList.swift +++ b/Meshtastic/Views/Messages/ChannelMessageList.swift @@ -11,7 +11,7 @@ import OSLog import SwiftUI struct ChannelMessageList: View { - @StateObject var appState = AppState.shared + @EnvironmentObject var appState: AppState @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @@ -80,7 +80,6 @@ struct ChannelMessageList: View { TapbackResponses(message: message) { appState.unreadChannelMessages = myInfo.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages context.refresh(myInfo, mergeChanges: true) } @@ -118,7 +117,6 @@ struct ChannelMessageList: View { try context.save() Logger.data.info("📖 [App] Read message \(message.messageId) ") appState.unreadChannelMessages = myInfo.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages context.refresh(myInfo, mergeChanges: true) } catch { Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)") @@ -131,9 +129,6 @@ struct ChannelMessageList: View { .padding([.top]) .scrollDismissesKeyboard(.immediately) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } if channel.allPrivateMessages.count > 0 { scrollView.scrollTo(channel.allPrivateMessages.last!.messageId) } diff --git a/Meshtastic/Views/Messages/Messages.swift b/Meshtastic/Views/Messages/Messages.swift index 35206d7c9..88a6f2a2e 100644 --- a/Meshtastic/Views/Messages/Messages.swift +++ b/Meshtastic/Views/Messages/Messages.swift @@ -14,52 +14,72 @@ import TipKit struct Messages: View { - @StateObject var appState = AppState.shared @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager + @ObservedObject + var router: Router + + @Binding + var unreadChannelMessages: Int + + @Binding + var unreadDirectMessages: Int + + // Aliases the navigation state for the NavigationSplitView sidebar selection + private var messagesSelection: Binding { + Binding( + get: { + guard case .messages(let state) = router.navigationState else { + return nil + } + return state + }, + set: { newValue in + router.navigationState = .messages(newValue) + } + ) + } + @State var node: NodeInfoEntity? @State private var userSelection: UserEntity? // Nothing selected by default. @State private var channelSelection: ChannelEntity? // Nothing selected by default. @State private var columnVisibility = NavigationSplitViewVisibility.all - enum MessagesSidebar { - case groupMessages - case directMessages - } - var body: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { - // Sidebar - List { - NavigationLink { - ChannelList(node: node) - } label: { - Image(systemName: "person.3") - .symbolRenderingMode(.hierarchical) - .foregroundColor(.accentColor) - .brightness(0.2) - .font(.title) - Text("channels") - .font(.title2) - .badge(appState.unreadChannelMessages) - .padding(.vertical) + List(selection: messagesSelection) { + NavigationLink(value: MessagesNavigationState.channels()) { + Label { + Text("channels") + .badge(unreadChannelMessages) + .font(.title2) + .padding() + } icon: { + Image(systemName: "person.3") + .symbolRenderingMode(.hierarchical) + .foregroundColor(.accentColor) + .font(.title2) + .padding() + } + } - NavigationLink { - UserList(node: node) - } label: { - Image(systemName: "person.circle") - .symbolRenderingMode(.hierarchical) - .foregroundColor(.accentColor) - .brightness(0.2) - .font(.largeTitle) - Text("direct.messages") - .font(.title2) - .badge(appState.unreadDirectMessages) - .padding(.vertical) + NavigationLink(value: MessagesNavigationState.directMessages()) { + Label { + Text("direct.messages") + .badge(unreadDirectMessages) + .font(.title2) + .padding() + } icon: { + Image(systemName: "person.circle") + .symbolRenderingMode(.hierarchical) + .foregroundColor(.accentColor) + .font(.title2) + .padding() + } } + if #available(iOS 17.0, macOS 14.0, *) { TipView(MessagesTip(), arrowEdge: .top) } @@ -67,48 +87,63 @@ struct Messages: View { .navigationTitle("messages") .navigationBarTitleDisplayMode(.large) .navigationBarItems(leading: MeshtasticLogo()) - .onChange(of: (appState.navigationPath)) { newPath in - - if (newPath?.hasPrefix("meshtastic://messages")) != nil { - - if let urlComponent = URLComponents(string: newPath ?? "") { - let queryItems = urlComponent.queryItems - let messageId = queryItems?.first(where: { $0.name == "messageId" })?.value - let channel = queryItems?.first(where: { $0.name == "channel" })?.value - - if let channel { - Logger.services.info("Deep Link Channel \(channel)") - // selectedNode = nodes.first(where: { $0.num == Int64(nodeNum ?? "-1") }) - // AppState.shared.navigationPath = nil - } else { - Logger.services.info("Channel Deep Link not found") - } - } - } + } content: { + if case .messages(.channels) = router.navigationState { + ChannelList(node: $node, channelSelection: $channelSelection) + } else if case .messages(.directMessages) = router.navigationState { + UserList(node: $node, userSelection: $userSelection) + } else if case .messages(nil) = router.navigationState { + Text("Select a conversation type") } - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - if UserDefaults.preferredPeripheralId.count > 0 { - let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest() - fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(UserDefaults.preferredPeripheralNum)) - do { - let fetchedNode = try context.fetch(fetchNodeInfoRequest) - // Found a node, check it for a region - if !fetchedNode.isEmpty { - node = fetchedNode[0] - } - } catch { - - } - } + } detail: { + if let myInfo = node?.myInfo, let channelSelection { + ChannelMessageList(myInfo: myInfo, channel: channelSelection) + } else if let userSelection { + UserMessageList(user: userSelection) + } else if case .messages(.channels) = router.navigationState { + Text("Select a channel") + } else if case .messages(.directMessages) = router.navigationState { + Text("Select a conversation") } + }.onChange(of: router.navigationState) { _ in + setupNavigationState() + } + } - } content: { + private func setupNavigationState() { + let nodeId = Int64(UserDefaults.preferredPeripheralNum) + if nodeId > 0 { + node = getNodeInfo(id: nodeId, context: context) + } - } detail: { + guard case .messages(let state) = router.navigationState else { + return + } + guard let state else { + channelSelection = nil + userSelection = nil + return + } + + switch state { + case .channels(channelId: let channelId, messageId: _): + if let channelId { + channelSelection = node?.myInfo?.channels?.first(where: { channel in + guard let channel = channel as? ChannelEntity else { return false } + return channel.id == channelId + }) as? ChannelEntity + } else { + channelSelection = nil + userSelection = nil + } + case .directMessages(userNum: let userNum, messageId: _): + if let userNum { + userSelection = getUser(id: userNum, context: context) + } else { + channelSelection = nil + userSelection = nil + } } } } diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 8a7b6333a..5c7214fe7 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -14,7 +14,6 @@ import TipKit struct UserList: View { - @StateObject var appState = AppState.shared @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager @State private var searchText = "" @@ -34,19 +33,20 @@ struct UserList: View { sortDescriptors: [NSSortDescriptor(key: "lastMessage", ascending: false), NSSortDescriptor(key: "userNode.favorite", ascending: false), NSSortDescriptor(key: "longName", ascending: true)], - animation: .default) - + animation: .default + ) private var users: FetchedResults - @State var node: NodeInfoEntity? - @State var selectedUserNum: Int64? - @State private var userSelection: UserEntity? // Nothing selected by default. + + @Binding var node: NodeInfoEntity? + @Binding var userSelection: UserEntity? + @State private var isPresentingDeleteUserMessagesConfirm: Bool = false var body: some View { let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current) let dateFormatString = (localeDateFormat ?? "MM/dd/YY") VStack { - List { + List(selection: $userSelection) { if #available(iOS 17.0, macOS 14.0, *) { TipView(ContactsTip(), arrowEdge: .bottom) } @@ -55,8 +55,8 @@ struct UserList: View { let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 )))) let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0 let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0 - if user.num != bleManager.connectedPeripheral?.num ?? 0 { - NavigationLink(destination: UserMessageList(user: user)) { + if user.num != bleManager.connectedPeripheral?.num ?? 0 { + NavigationLink(value: user) { ZStack { Image(systemName: "circle.fill") .opacity(user.unreadMessages > 0 ? 1 : 0) @@ -161,7 +161,6 @@ struct UserList: View { Button(role: .destructive) { deleteUserMessages(user: userSelection!, context: context) context.refresh(node!.user!, mergeChanges: true) - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages } label: { Text("delete") } @@ -207,13 +206,7 @@ struct UserList: View { .onChange(of: distanceFilter) { _ in searchUserList() } - .onChange(of: selectedUserNum) { newUserNum in - userSelection = users.first(where: { $0.num == newUserNum }) - } .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } searchUserList() } .safeAreaInset(edge: .bottom, alignment: .trailing) { diff --git a/Meshtastic/Views/Messages/UserMessageList.swift b/Meshtastic/Views/Messages/UserMessageList.swift index 41c83f51a..6514ead8f 100644 --- a/Meshtastic/Views/Messages/UserMessageList.swift +++ b/Meshtastic/Views/Messages/UserMessageList.swift @@ -10,10 +10,11 @@ import CoreData import OSLog struct UserMessageList: View { - @StateObject var appState = AppState.shared - @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager + @EnvironmentObject var appState: AppState + @EnvironmentObject var bleManager: BLEManager + @Environment(\.managedObjectContext) var context + // Keyboard State @FocusState var messageFieldFocused: Bool // View State Items @@ -64,7 +65,6 @@ struct UserMessageList: View { TapbackResponses(message: message) { appState.unreadDirectMessages = user.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages } HStack { @@ -102,7 +102,6 @@ struct UserMessageList: View { try context.save() Logger.data.info("📖 [App] Read message \(message.messageId) ") appState.unreadDirectMessages = user.unreadMessages - UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages } catch { Logger.data.error("Failed to read message \(message.messageId): \(error.localizedDescription)") @@ -116,9 +115,6 @@ struct UserMessageList: View { .padding([.top]) .scrollDismissesKeyboard(.immediately) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } if user.messageList.count > 0 { scrollView.scrollTo(user.messageList.last!.messageId) } diff --git a/Meshtastic/Views/Nodes/DetectionSensorLog.swift b/Meshtastic/Views/Nodes/DetectionSensorLog.swift index da2e4e09f..06c468caa 100644 --- a/Meshtastic/Views/Nodes/DetectionSensorLog.swift +++ b/Meshtastic/Views/Nodes/DetectionSensorLog.swift @@ -124,11 +124,6 @@ struct DetectionSensorLog: View { ZStack { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } .fileExporter( isPresented: $isExporting, document: CsvDocument(emptyCsv: exportString), diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index f111e21d3..bbabf31e7 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -230,11 +230,6 @@ struct DeviceMetricsLog: View { ZStack { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } .fileExporter( isPresented: $isExporting, document: CsvDocument(emptyCsv: exportString), diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index d24ad8310..f0d2dd04c 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -199,11 +199,6 @@ struct EnvironmentMetricsLog: View { ZStack { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } .fileExporter( isPresented: $isExporting, document: CsvDocument(emptyCsv: exportString), diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift index 527a896ed..edfec3bee 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapContent/MeshMapContent.swift @@ -11,7 +11,6 @@ import MapKit @available(iOS 17.0, macOS 14.0, *) struct MeshMapContent: MapContent { - @StateObject var appState = AppState.shared /// Parameters @Binding var showUserLocation: Bool @AppStorage("meshMapShowNodeHistory") private var showNodeHistory = false diff --git a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift index 8d4f79b4e..175737455 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/WaypointForm.swift @@ -184,11 +184,11 @@ struct WaypointForm: View { Menu { Button("For me", action: { - bleManager.context!.delete(waypoint) + bleManager.context.delete(waypoint) do { - try bleManager.context!.save() + try bleManager.context.save() } catch { - bleManager.context!.rollback() + bleManager.context.rollback() } dismiss() }) Button("For everyone", action: { @@ -213,11 +213,11 @@ struct WaypointForm: View { newWaypoint.expire = UInt32(1) if bleManager.sendWaypoint(waypoint: newWaypoint) { - bleManager.context!.delete(waypoint) + bleManager.context.delete(waypoint) do { - try bleManager.context!.save() + try bleManager.context.save() } catch { - bleManager.context!.rollback() + bleManager.context.rollback() } dismiss() } else { @@ -351,7 +351,7 @@ struct WaypointForm: View { } .onAppear { if waypoint.id > 0 { - let waypoint = getWaypoint(id: Int64(waypoint.id), context: bleManager.context!) + let waypoint = getWaypoint(id: Int64(waypoint.id), context: bleManager.context) name = waypoint.name ?? "Dropped Pin" description = waypoint.longDescription ?? "" icon = String(UnicodeScalar(Int(waypoint.icon)) ?? "📍") diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index f5445f18a..fc9b44c14 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -396,11 +396,6 @@ struct NodeDetail: View { } } .listStyle(.insetGrouped) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } } } } diff --git a/Meshtastic/Views/Nodes/MeshMap.swift b/Meshtastic/Views/Nodes/MeshMap.swift index caa3f5d62..71cbfaf28 100644 --- a/Meshtastic/Views/Nodes/MeshMap.swift +++ b/Meshtastic/Views/Nodes/MeshMap.swift @@ -19,7 +19,10 @@ struct MeshMap: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @StateObject var appState = AppState.shared + + @ObservedObject + var router: Router + /// Parameters @State var showUserLocation: Bool = true /// Map State User Defaults @@ -106,33 +109,10 @@ struct MeshMap: View { .sheet(isPresented: $isEditingSettings) { MapSettingsForm(traffic: $showTraffic, pointsOfInterest: $showPointsOfInterest, mapLayer: $selectedMapLayer, meshMap: $isMeshMap) } -// .onChange(of: (appState.navigationPath)) { newPath in -// -// if ((newPath?.hasPrefix("meshtastic://open-waypoint")) != nil) { -// guard let url = URL(string: appState.navigationPath ?? "NONE") else { -// logger.error("Invalid URL") -// return -// } -// guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { -// logger.error("Invalid URL Components") -// return -// } -// guard let action = components.host, action == "open-waypoint" else { -// logger.error("Unknown waypoint URL action") -// return -// } -// guard let waypointId = components.queryItems?.first(where: { $0.name == "id" })?.value else { -// logger.error("Waypoint id not found") -// return -// } -// guard let waypoint = waypoints.first(where: { $0.id == Int64(waypointId) }) else { -// logger.error("Waypoint not found") -// return -// } -// //showWaypoints = true -// //position = .camera(MapCamera(centerCoordinate: waypoint.coordinate, distance: 1000, heading: 0, pitch: 60)) -// } -// } + .onChange(of: router.navigationState) { + guard case .map(let selectedNodeNum) = router.navigationState else { return } + //TODO: handle deep link for waypoints + } .onChange(of: (selectedMapLayer)) { newMapLayer in switch selectedMapLayer { case .standard: diff --git a/Meshtastic/Views/Nodes/NodeList.swift b/Meshtastic/Views/Nodes/NodeList.swift index 30b231c50..4d7ec453f 100644 --- a/Meshtastic/Views/Nodes/NodeList.swift +++ b/Meshtastic/Views/Nodes/NodeList.swift @@ -9,8 +9,15 @@ import CoreLocation import OSLog struct NodeList: View { + @Environment(\.managedObjectContext) + var context + + @EnvironmentObject + var bleManager: BLEManager + + @ObservedObject + var router: Router - @StateObject var appState = AppState.shared @State private var columnVisibility = NavigationSplitViewVisibility.all @State private var selectedNode: NodeInfoEntity? @State private var searchText = "" @@ -37,9 +44,6 @@ struct NodeList: View { @SceneStorage("selectedDetailView") var selectedDetailView: String? - @Environment(\.managedObjectContext) var context - @EnvironmentObject var bleManager: BLEManager - @FetchRequest( sortDescriptors: [ NSSortDescriptor(key: "favorite", ascending: false), @@ -50,6 +54,13 @@ struct NodeList: View { ) var nodes: FetchedResults + var connectedNode: NodeInfoEntity? { + getNodeInfo( + id: bleManager.connectedPeripheral?.num ?? 0, + context: context + ) + } + @ViewBuilder func contextMenuActions( node: NodeInfoEntity, @@ -89,8 +100,6 @@ struct NodeList: View { var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { - let connectedNodeNum = Int(bleManager.connectedPeripheral?.num ?? 0) - let connectedNode = nodes.first(where: { $0.num == connectedNodeNum }) List(nodes, id: \.self, selection: $selectedNode) { node in NodeListItem( node: node, @@ -157,10 +166,7 @@ struct NodeList: View { if let node = selectedNode { NavigationStack { NodeDetail( - connectedNode: nodes.first(where: { - let connectedNodeNum = Int(bleManager.connectedPeripheral?.num ?? 0) - return $0.num == connectedNodeNum - }), + connectedNode: connectedNode, node: node, columnVisibility: columnVisibility ) @@ -184,7 +190,6 @@ struct NodeList: View { } ) } - } else { if #available (iOS 17, *) { ContentUnavailableView("select.node", systemImage: "flipphone") @@ -198,7 +203,6 @@ struct NodeList: View { } else { Text("Select something to view") } - } .navigationSplitViewStyle(.balanced) .onChange(of: searchText) { _ in @@ -242,29 +246,22 @@ struct NodeList: View { await searchNodeList() } } - .onChange(of: (appState.navigationPath)) { newPath in - - guard let deepLink = newPath else { - return + .onChange(of: distanceFilter) { _ in + Task { + await searchNodeList() } - if deepLink.hasPrefix("meshtastic://nodes") { - - if let urlComponent = URLComponents(string: deepLink) { - let queryItems = urlComponent.queryItems - let nodeNum = queryItems?.first(where: { $0.name == "nodenum" })?.value - if nodeNum == nil { - Logger.data.debug("nodeNum not found") - } else { - selectedNode = nodes.first(where: { $0.num == Int64(nodeNum ?? "-1") }) - AppState.shared.navigationPath = nil - } + } + .onChange(of: router.navigationState) { _ in + // Handle deep link routing + if case .nodes(let selected) = router.navigationState { + self.selectedNode = selected.flatMap { + getNodeInfo(id: $0, context: context) } + } else { + self.selectedNode = nil } } .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } Task { await searchNodeList() } diff --git a/Meshtastic/Views/Nodes/NodeMap.swift b/Meshtastic/Views/Nodes/NodeMap.swift index cd79ef0dc..5630fa8fd 100644 --- a/Meshtastic/Views/Nodes/NodeMap.swift +++ b/Meshtastic/Views/Nodes/NodeMap.swift @@ -13,7 +13,10 @@ import CoreData struct NodeMap: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @StateObject var appState = AppState.shared + + @ObservedObject + var router: Router + @State var selectedMapLayer: MapLayer = UserDefaults.mapLayer @State var enableMapRecentering: Bool = UserDefaults.enableMapRecentering @State var enableMapRouteLines: Bool = UserDefaults.enableMapRouteLines @@ -240,9 +243,6 @@ struct NodeMap: View { }) .onAppear(perform: { UIApplication.shared.isIdleTimerDisabled = true - if self.bleManager.context == nil { - self.bleManager.context = context - } }) .onDisappear(perform: { UIApplication.shared.isIdleTimerDisabled = false diff --git a/Meshtastic/Views/Nodes/PaxCounterLog.swift b/Meshtastic/Views/Nodes/PaxCounterLog.swift index 9042f091d..6d764c63c 100644 --- a/Meshtastic/Views/Nodes/PaxCounterLog.swift +++ b/Meshtastic/Views/Nodes/PaxCounterLog.swift @@ -209,11 +209,6 @@ struct PaxCounterLog: View { ZStack { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } .fileExporter( isPresented: $isExporting, document: CsvDocument(emptyCsv: exportString), diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index 1c7ef170d..433548305 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -179,10 +179,5 @@ struct PositionLog: View { ZStack { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } } } diff --git a/Meshtastic/Views/Nodes/TraceRouteLog.swift b/Meshtastic/Views/Nodes/TraceRouteLog.swift index cd44cd30b..9556298af 100644 --- a/Meshtastic/Views/Nodes/TraceRouteLog.swift +++ b/Meshtastic/Views/Nodes/TraceRouteLog.swift @@ -145,10 +145,5 @@ struct TraceRouteLog: View { ZStack { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } } } diff --git a/Meshtastic/Views/Settings/AppSettings.swift b/Meshtastic/Views/Settings/AppSettings.swift index d99d510b2..f9f46cbcf 100644 --- a/Meshtastic/Views/Settings/AppSettings.swift +++ b/Meshtastic/Views/Settings/AppSettings.swift @@ -103,10 +103,5 @@ struct AppSettings: View { ZStack { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } } } diff --git a/Meshtastic/Views/Settings/Channels.swift b/Meshtastic/Views/Settings/Channels.swift index 816c6eb1f..bec869589 100644 --- a/Meshtastic/Views/Settings/Channels.swift +++ b/Meshtastic/Views/Settings/Channels.swift @@ -280,11 +280,6 @@ struct Channels: View { ZStack { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) - .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - } } } diff --git a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift index 8b4033dba..b43813c29 100644 --- a/Meshtastic/Views/Settings/Config/BluetoothConfig.swift +++ b/Meshtastic/Views/Settings/Config/BluetoothConfig.swift @@ -107,9 +107,6 @@ struct BluetoothConfig: View { } ) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setBluetoothValues() // Need to request a BluetoothConfig from the remote node before allowing changes if let connectedPeripheral = bleManager.connectedPeripheral, let node, node.bluetoothConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/DeviceConfig.swift b/Meshtastic/Views/Settings/Config/DeviceConfig.swift index 1a8722697..114898ca7 100644 --- a/Meshtastic/Views/Settings/Config/DeviceConfig.swift +++ b/Meshtastic/Views/Settings/Config/DeviceConfig.swift @@ -238,9 +238,6 @@ struct DeviceConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setDeviceValues() // Need to request a LoRaConfig from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.deviceConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/DisplayConfig.swift b/Meshtastic/Views/Settings/Config/DisplayConfig.swift index 867f616d8..f800ebb68 100644 --- a/Meshtastic/Views/Settings/Config/DisplayConfig.swift +++ b/Meshtastic/Views/Settings/Config/DisplayConfig.swift @@ -159,9 +159,6 @@ struct DisplayConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setDisplayValues() // Need to request a LoRaConfig from the remote node before allowing changes diff --git a/Meshtastic/Views/Settings/Config/LoRaConfig.swift b/Meshtastic/Views/Settings/Config/LoRaConfig.swift index 210675b2b..c98e162ca 100644 --- a/Meshtastic/Views/Settings/Config/LoRaConfig.swift +++ b/Meshtastic/Views/Settings/Config/LoRaConfig.swift @@ -226,9 +226,6 @@ struct LoRaConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setLoRaValues() // Need to request a LoRaConfig from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.loRaConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift index 3d3d35867..3fe5560dc 100644 --- a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift @@ -85,9 +85,6 @@ struct AmbientLightingConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setAmbientLightingConfigValue() // Need to request a Ambient Lighting Config from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.ambientLightingConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift index 37e16f931..670e24e19 100644 --- a/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/CannedMessagesConfig.swift @@ -229,9 +229,6 @@ struct CannedMessagesConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setCannedMessagesValues() // Need to request a CannedMessagesModuleConfig from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.cannedMessageConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift index 2e8f715b7..480c4e24c 100644 --- a/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/DetectionSensorConfig.swift @@ -185,9 +185,6 @@ struct DetectionSensorConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setDetectionSensorValues() // Need to request a Detection Sensor Module Config from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.detectionSensorConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift index a2adc9b6f..abdca8a22 100644 --- a/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/ExternalNotificationConfig.swift @@ -195,9 +195,6 @@ struct ExternalNotificationConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setExternalNotificationValues() // Need to request a TelemetryModuleConfig from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.externalNotificationConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift index 7eb5a9453..f923e560f 100644 --- a/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/MQTTConfig.swift @@ -357,9 +357,6 @@ struct MQTTConfig: View { } } .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setMqttValues() // Need to request a TelemetryModuleConfig from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.mqttConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift index ceb49fa61..d7670a7c3 100644 --- a/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/PaxCounterConfig.swift @@ -58,10 +58,6 @@ struct PaxCounterConfig: View { ) }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - setPaxValues() // Need to request a PAX Counter module config from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.paxCounterConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift index 2af3b5fda..0bf99d659 100644 --- a/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RangeTestConfig.swift @@ -77,9 +77,6 @@ struct RangeTestConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setRangeTestValues() // Need to request a RangeTestModule Config from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.rangeTestConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift index 6df3e5040..3128fffe5 100644 --- a/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/RtttlConfig.swift @@ -67,9 +67,6 @@ struct RtttlConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setRtttLConfigValue() // Need to request a Rtttl Config from the remote node before allowing changes if bleManager.connectedPeripheral != nil && (node?.rtttlConfig == nil || node?.rtttlConfig?.ringtone?.count ?? 0 == 0) { diff --git a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift index c4f27c6d6..813e1328d 100644 --- a/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/SerialConfig.swift @@ -133,9 +133,6 @@ struct SerialConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setSerialValues() // Need to request a SerialModuleConfig from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.serialConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift index d5aba9cb6..e73380f30 100644 --- a/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/StoreForwardConfig.swift @@ -142,10 +142,6 @@ struct StoreForwardConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - // Need to request a Detection Sensor Module Config from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.storeForwardConfig == nil { Logger.mesh.debug("empty store and forward module config") diff --git a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift index eef314add..a1f827b7c 100644 --- a/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/TelemetryConfig.swift @@ -130,9 +130,6 @@ struct TelemetryConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setTelemetryValues() // Need to request a TelemetryModuleConfig from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.telemetryConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/NetworkConfig.swift b/Meshtastic/Views/Settings/Config/NetworkConfig.swift index 0dedaeeda..d969eab96 100644 --- a/Meshtastic/Views/Settings/Config/NetworkConfig.swift +++ b/Meshtastic/Views/Settings/Config/NetworkConfig.swift @@ -114,9 +114,6 @@ struct NetworkConfig: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setNetworkValues() // Need to request a NetworkConfig from the remote node before allowing changes if bleManager.connectedPeripheral != nil && node?.networkConfig == nil { diff --git a/Meshtastic/Views/Settings/Config/PositionConfig.swift b/Meshtastic/Views/Settings/Config/PositionConfig.swift index a0d4ac97f..aa6960a09 100644 --- a/Meshtastic/Views/Settings/Config/PositionConfig.swift +++ b/Meshtastic/Views/Settings/Config/PositionConfig.swift @@ -377,9 +377,6 @@ struct PositionConfig: View { } ) .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } setPositionValues() supportedVersion = bleManager.connectedVersion == "0.0.0" || self.minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedAscending || minimumVersion.compare(bleManager.connectedVersion, options: .numeric) == .orderedSame // Need to request a PositionConfig from the remote node before allowing changes diff --git a/Meshtastic/Views/Settings/Config/PowerConfig.swift b/Meshtastic/Views/Settings/Config/PowerConfig.swift index 15b7e44d6..d8de7e446 100644 --- a/Meshtastic/Views/Settings/Config/PowerConfig.swift +++ b/Meshtastic/Views/Settings/Config/PowerConfig.swift @@ -118,10 +118,6 @@ struct PowerConfig: View { } } .onAppear { - if self.bleManager.context == nil { - self.bleManager.context = context - } - Api().loadDeviceHardwareData { (hw) in for device in hw { diff --git a/Meshtastic/Views/Settings/Settings.swift b/Meshtastic/Views/Settings/Settings.swift index 487a35850..d15302749 100644 --- a/Meshtastic/Views/Settings/Settings.swift +++ b/Meshtastic/Views/Settings/Settings.swift @@ -14,44 +14,22 @@ import TipKit struct Settings: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var bleManager: BLEManager - @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "favorite", ascending: false), - NSSortDescriptor(key: "user.longName", ascending: true)], animation: .default) + @FetchRequest( + sortDescriptors: [ + NSSortDescriptor(key: "favorite", ascending: false), + NSSortDescriptor(key: "user.longName", ascending: true) + ], + animation: .default + ) private var nodes: FetchedResults + @State private var selectedNode: Int = 0 @State private var preferredNodeNum: Int = 0 - @State private var selection: SettingsSidebar = .about - - enum SettingsSidebar { - case appSettings - case routes - case routeRecorder - case shareChannels - case userConfig - case loraConfig - case channelConfig - case bluetoothConfig - case deviceConfig - case displayConfig - case networkConfig - case paxCounterConfig - case positionConfig - case powerConfig - case ambientLightingConfig - case cannedMessagesConfig - case detectionSensorConfig - case externalNotificationConfig - case mqttConfig - case rangeTestConfig - case ringtoneConfig - case serialConfig - case storeAndForwardConfig - case telemetryConfig - case meshLog - case adminMessageLog - case about - case appLog - case appData - } + + @ObservedObject + var router: Router + + // MARK: Views var radioConfigurationSection: some View { Section("radio.configuration") { @@ -77,9 +55,7 @@ struct Settings: View { .foregroundColor(.gray) } - NavigationLink { - LoRaConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { + NavigationLink(value: SettingsNavigationState.lora) { Label { Text("lora") } icon: { @@ -87,72 +63,272 @@ struct Settings: View { .rotationEffect(.degrees(-90)) } } - .tag(SettingsSidebar.loraConfig) - NavigationLink { - Channels(node: node) - } label: { + NavigationLink(value: SettingsNavigationState.channels) { Label { Text("channels") } icon: { Image(systemName: "fibrechannel") } } - .tag(SettingsSidebar.channelConfig) .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) - NavigationLink { - ShareChannels(node: node) - } label: { + NavigationLink(value: SettingsNavigationState.shareQRCode) { Label { Text("share.channels") } icon: { Image(systemName: "qrcode") } } - .tag(SettingsSidebar.shareChannels) + .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) + } + } + + var deviceConfigurationSection: some View { + Section("device.configuration") { + NavigationLink(value: SettingsNavigationState.user) { + Label { + Text("user") + } icon: { + Image(systemName: "person.crop.rectangle.fill") + } + } + + NavigationLink(value: SettingsNavigationState.bluetooth) { + Label { + Text("bluetooth") + } icon: { + Image(systemName: "antenna.radiowaves.left.and.right") + } + } + + NavigationLink(value: SettingsNavigationState.device) { + Label { + Text("device") + } icon: { + Image(systemName: "flipphone") + } + } + + NavigationLink(value: SettingsNavigationState.display) { + Label { + Text("display") + } icon: { + Image(systemName: "display") + } + } + + NavigationLink(value: SettingsNavigationState.network) { + Label { + Text("network") + } icon: { + Image(systemName: "network") + } + } + + NavigationLink(value: SettingsNavigationState.position) { + Label { + Text("position") + } icon: { + Image(systemName: "location") + } + } + + NavigationLink(value: SettingsNavigationState.power) { + Label { + Text("config.power.settings") + } icon: { + Image(systemName: "bolt.fill") + } + } + } + } + + var moduleConfigurationSection: some View { + Section("module.configuration") { + if #available(iOS 17.0, macOS 14.0, *) { + NavigationLink(value: SettingsNavigationState.ambientLighting) { + Label { + Text("ambient.lighting") + } icon: { + Image(systemName: "light.max") + } + } + } + + NavigationLink(value: SettingsNavigationState.cannedMessages) { + Label { + Text("canned.messages") + } icon: { + Image(systemName: "list.bullet.rectangle.fill") + } + } + + NavigationLink(value: SettingsNavigationState.detectionSensor) { + Label { + Text("detection.sensor") + } icon: { + Image(systemName: "sensor") + } + } + + NavigationLink(value: SettingsNavigationState.externalNotification) { + Label { + Text("external.notification") + } icon: { + Image(systemName: "megaphone") + } + } + + NavigationLink(value: SettingsNavigationState.mqtt) { + Label { + Text("mqtt") + } icon: { + Image(systemName: "dot.radiowaves.up.forward") + } + } + + NavigationLink(value: SettingsNavigationState.rangeTest) { + Label { + Text("range.test") + } icon: { + Image(systemName: "point.3.connected.trianglepath.dotted") + } + } + + if let node = nodes.first(where: { $0.num == preferredNodeNum }), + node.metadata?.hasWifi ?? false { + NavigationLink(value: SettingsNavigationState.paxCounter) { + Label { + Text("config.module.paxcounter.settings") + } icon: { + Image(systemName: "figure.walk.motion") + } + } + } + + NavigationLink(value: SettingsNavigationState.ringtone) { + Label { + Text("ringtone") + } icon: { + Image(systemName: "music.note.list") + } + } + + NavigationLink(value: SettingsNavigationState.serial) { + Label { + Text("serial") + } icon: { + Image(systemName: "terminal") + } + } + + NavigationLink(value: SettingsNavigationState.storeAndForward) { + Label { + Text("storeforward") + } icon: { + Image(systemName: "envelope.arrow.triangle.branch") + } + } + + NavigationLink(value: SettingsNavigationState.telemetry) { + Label { + Text("telemetry") + } icon: { + Image(systemName: "chart.xyaxis.line") + } + } + } + } + + var loggingSection: some View { + Section(header: Text("logging")) { + NavigationLink(value: SettingsNavigationState.meshLog) { + Label { + Text("mesh.log") + } icon: { + Image(systemName: "list.bullet.rectangle") + } + } + + if #available (iOS 17.4, *) { + NavigationLink(value: SettingsNavigationState.debugLogs) { + Label { + Text("Debug Logs") + } icon: { + Image(systemName: "stethoscope") + } + } + } + } + } + + var developersSection: some View { + Section(header: Text("Developers")) { + NavigationLink(value: SettingsNavigationState.appFiles) { + Label { + Text("App Files") + } icon: { + Image(systemName: "folder") + } + } + } + } + + var firmwareSection: some View { + Section(header: Text("Firmware")) { + NavigationLink(value: SettingsNavigationState.firmwareUpdates) { + Label { + Text("Firmware Updates") + } icon: { + Image(systemName: "arrow.up.arrow.down.square") + } + } .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) } } var body: some View { - NavigationSplitView { + NavigationStack( + path: Binding<[SettingsNavigationState]>( + get: { + guard case .settings(let route) = router.navigationState, let setting = route else { + return [] + } + return [setting] + }, + set: { newPath in + router.navigationState = .settings(newPath.first) + } + ) + ) { let node = nodes.first(where: { $0.num == preferredNodeNum }) List { - NavigationLink { - AboutMeshtastic() - } label: { + NavigationLink(value: SettingsNavigationState.about) { Label { Text("about.meshtastic") } icon: { Image(systemName: "questionmark.app") } } - .tag(SettingsSidebar.about) - NavigationLink { - AppSettings() - } label: { + + NavigationLink(value: SettingsNavigationState.appSettings) { Label { Text("appsettings") } icon: { Image(systemName: "gearshape") } } - .tag(SettingsSidebar.appSettings) if #available(iOS 17.0, macOS 14.0, *) { - NavigationLink { - Routes() - } label: { + NavigationLink(value: SettingsNavigationState.routes) { Label { Text("routes") } icon: { Image(systemName: "road.lanes.curved.right") } } - .tag(SettingsSidebar.routes) - NavigationLink { - RouteRecorder() - } label: { + + NavigationLink(value: SettingsNavigationState.routeRecorder) { Label { Text("route.recorder") } icon: { @@ -160,10 +336,9 @@ struct Settings: View { .foregroundColor(.red) } } - .tag(SettingsSidebar.routeRecorder) } - let hasAdmin = node?.myInfo?.adminIndex ?? 0 > 0 ? true : false + let hasAdmin = node?.myInfo?.adminIndex ?? 0 > 0 if !(node?.deviceConfig?.isManaged ?? false) { if bleManager.connectedPeripheral != nil { @@ -225,246 +400,84 @@ struct Settings: View { } } radioConfigurationSection - Section("device.configuration") { - NavigationLink { - UserConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("user") - } icon: { - Image(systemName: "person.crop.rectangle.fill") - } - } - .tag(SettingsSidebar.userConfig) - NavigationLink { - BluetoothConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("bluetooth") - } icon: { - Image(systemName: "antenna.radiowaves.left.and.right") - } - } - .tag(SettingsSidebar.bluetoothConfig) - NavigationLink { - DeviceConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("device") - } icon: { - Image(systemName: "flipphone") - } - } - .tag(SettingsSidebar.deviceConfig) - NavigationLink { - DisplayConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("display") - } icon: { - Image(systemName: "display") - } - } - .tag(SettingsSidebar.displayConfig) - NavigationLink { - NetworkConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("network") - } icon: { - Image(systemName: "network") - } - } - .tag(SettingsSidebar.networkConfig) - NavigationLink { - PositionConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("position") - } icon: { - Image(systemName: "location") - } - } - .tag(SettingsSidebar.positionConfig) - - NavigationLink { - PowerConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("config.power.settings") - } icon: { - Image(systemName: "bolt.fill") - } - } - .tag(SettingsSidebar.powerConfig) - } - Section("module.configuration") { - if #available(iOS 17.0, macOS 14.0, *) { - NavigationLink { - AmbientLightingConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("ambient.lighting") - } icon: { - Image(systemName: "light.max") - } - } - .tag(SettingsSidebar.ambientLightingConfig) - } - NavigationLink { - CannedMessagesConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("canned.messages") - } icon: { - Image(systemName: "list.bullet.rectangle.fill") - } - } - .tag(SettingsSidebar.cannedMessagesConfig) - NavigationLink { - DetectionSensorConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("detection.sensor") - } icon: { - Image(systemName: "sensor") - } - } - .tag(SettingsSidebar.detectionSensorConfig) - NavigationLink { - ExternalNotificationConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("external.notification") - } icon: { - Image(systemName: "megaphone") - } - } - .tag(SettingsSidebar.externalNotificationConfig) - NavigationLink { - MQTTConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("mqtt") - } icon: { - Image(systemName: "dot.radiowaves.up.forward") - } - } - .tag(SettingsSidebar.mqttConfig) - NavigationLink { - RangeTestConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("range.test") - } icon: { - Image(systemName: "point.3.connected.trianglepath.dotted") - } - } - .tag(SettingsSidebar.rangeTestConfig) - if node?.metadata?.hasWifi ?? false { - NavigationLink { - PaxCounterConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("config.module.paxcounter.settings") - } icon: { - Image(systemName: "figure.walk.motion") - } - } - .tag(SettingsSidebar.paxCounterConfig) - } - NavigationLink { - RtttlConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("ringtone") - } icon: { - Image(systemName: "music.note.list") - } - } - .tag(SettingsSidebar.ringtoneConfig) - NavigationLink { - SerialConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("serial") - } icon: { - Image(systemName: "terminal") - } - } - .tag(SettingsSidebar.serialConfig) - NavigationLink { - StoreForwardConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("storeforward") - } icon: { - Image(systemName: "envelope.arrow.triangle.branch") - } - } - .tag(SettingsSidebar.storeAndForwardConfig) - NavigationLink { - TelemetryConfig(node: nodes.first(where: { $0.num == selectedNode })) - } label: { - Label { - Text("telemetry") - } icon: { - Image(systemName: "chart.xyaxis.line") - } - } - .tag(SettingsSidebar.telemetryConfig) + deviceConfigurationSection + moduleConfigurationSection + loggingSection +#if DEBUG + developersSection +#endif + firmwareSection + } + } + .navigationDestination(for: SettingsNavigationState.self) { destination in + let node = nodes.first(where: { $0.num == preferredNodeNum }) + switch destination { + case .about: + AboutMeshtastic() + case .appSettings: + AppSettings() + case .routes: + if #available(iOS 17.0, *) { + Routes() } - Section(header: Text("logging")) { - NavigationLink { - MeshLog() - } label: { - Label { - Text("mesh.log") - } icon: { - Image(systemName: "list.bullet.rectangle") - } - } - .tag(SettingsSidebar.meshLog) - if #available (iOS 17.4, *) { - NavigationLink { - AppLog() - } label: { - Label { - Text("Debug Logs") - } icon: { - Image(systemName: "stethoscope") - } - } - .tag(SettingsSidebar.appLog) - } + case .routeRecorder: + if #available(iOS 17.0, *) { + RouteRecorder() } -#if DEBUG - Section(header: Text("Developers")) { - NavigationLink { - AppData() - } label: { - Label { - Text("App Files") - } icon: { - Image(systemName: "folder") - } - } - .tag(SettingsSidebar.appData) + case .lora: + LoRaConfig(node: nodes.first(where: { $0.num == selectedNode })) + case .channels: + Channels(node: node) + case .shareQRCode: + ShareChannels(node: node) + case .user: + UserConfig(node: node) + case .bluetooth: + BluetoothConfig(node: node) + case .device: + DeviceConfig(node: node) + case .display: + DisplayConfig(node: node) + case .network: + NetworkConfig(node: node) + case .position: + PositionConfig(node: node) + case .power: + PowerConfig(node: node) + case .ambientLighting: + if #available(iOS 17.0, *) { + AmbientLightingConfig(node: node) } -#endif - Section(header: Text("Firmware")) { - NavigationLink { - Firmware(node: nodes.first(where: { $0.num == preferredNodeNum })) - } label: { - Label { - Text("Firmware Updates") - } icon: { - Image(systemName: "arrow.up.arrow.down.square") - } - } - .tag(SettingsSidebar.about) - .disabled(selectedNode > 0 && selectedNode != preferredNodeNum) + case .cannedMessages: + CannedMessagesConfig(node: node) + case .detectionSensor: + DetectionSensorConfig(node: node) + case .externalNotification: + ExternalNotificationConfig(node: node) + case .mqtt: + MQTTConfig(node: node) + case .rangeTest: + RangeTestConfig(node: node) + case .paxCounter: + PaxCounterConfig(node: node) + case .ringtone: + RtttlConfig(node: node) + case .serial: + SerialConfig(node: node) + case .storeAndForward: + StoreForwardConfig(node: node) + case .telemetry: + TelemetryConfig(node: node) + case .meshLog: + MeshLog() + case .debugLogs: + if #available(iOS 17.4, *) { + AppLog() } + case .appFiles: + AppData() + case .firmwareUpdates: + Firmware(node: node) } } .onChange(of: UserDefaults.preferredPeripheralNum ) { newConnectedNode in @@ -489,18 +502,11 @@ struct Settings: View { } } } - .listStyle(GroupedListStyle()) + .listStyle(.insetGrouped) .navigationTitle("settings") - .navigationBarItems(leading: - MeshtasticLogo() + .navigationBarItems( + leading: MeshtasticLogo() ) } - detail: { - if #available (iOS 17, *) { - ContentUnavailableView("select.menu.item", systemImage: "gear") - } else { - Text("select.menu.item") - } - } } } diff --git a/Meshtastic/Views/Settings/ShareChannels.swift b/Meshtastic/Views/Settings/ShareChannels.swift index 3859693e4..136b9563c 100644 --- a/Meshtastic/Views/Settings/ShareChannels.swift +++ b/Meshtastic/Views/Settings/ShareChannels.swift @@ -238,7 +238,6 @@ struct ShareChannels: View { ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?") }) .onAppear { - bleManager.context = context generateChannelSet() } .onChange(of: includeChannel0) { _ in generateChannelSet() } diff --git a/MeshtasticTests/RouterTests.swift b/MeshtasticTests/RouterTests.swift new file mode 100644 index 000000000..81b07276d --- /dev/null +++ b/MeshtasticTests/RouterTests.swift @@ -0,0 +1,57 @@ +import Foundation +import XCTest + +@testable import Meshtastic + +final class RouterTests: XCTestCase { + + func testInitialState() throws { + XCTAssertEqual(Router().navigationState, .bluetooth) + } + + func testRouteTo() throws { + let router = Router(navigationState: .bluetooth) + router.route(to: .settings(.about)) + XCTAssertEqual(router.navigationState, .settings(.about)) + } + + func testRouteURL() throws { + // Messages + try assertRoute("meshtastic:///messages", .messages()) + try assertRoute( + "meshtastic:///messages?channelId=0&messageId=1122334455", + .messages(.channels(channelId: 0, messageId: 1122334455)) + ) + try assertRoute( + "meshtastic:///messages?userNum=123456789&messageId=9876543210", + .messages(.directMessages(userNum: 123456789, messageId: 9876543210)) + ) + + // Bluetooth + try assertRoute("meshtastic:///bluetooth", .bluetooth) + + // Nodes + try assertRoute("meshtastic:///nodes", .nodes()) + try assertRoute("meshtastic:///nodes?nodenum=1234567890", .nodes(selectedNodeNum: 1234567890)) + + // Map + try assertRoute("meshtastic:///map", .map()) + try assertRoute("meshtastic:///map?waypointId=123456", .map(.waypoint(123456))) + try assertRoute("meshtastic:///map?nodenum=1234567890", .map(.selectedNode(1234567890))) + + // Settings + try assertRoute("meshtastic:///settings", .settings()) + try assertRoute("meshtastic:///settings/about", .settings(.about)) + try assertRoute("meshtastic:///settings/invalidSetting", .settings()) + } + + private func assertRoute( + router: Router = Router(), + _ urlString: String, + _ destination: NavigationState + ) throws { + let url = try XCTUnwrap(URL(string: urlString)) + router.route(url: url) + XCTAssertEqual(router.navigationState, destination) + } +} diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 80d1fb50b..396aaac9b 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -14,7 +14,7 @@ struct WidgetsLiveActivity: Widget { ActivityConfiguration(for: MeshActivityAttributes.self) { context in LiveActivityView(nodeName: context.attributes.name, channelUtilization: context.state.channelUtilization, airtime: context.state.airtime, batteryLevel: context.state.batteryLevel, nodes: 17, nodesOnline: 7, timerRange: context.state.timerRange) - .widgetURL(URL(string: "meshtastic://node/\(context.attributes.name)")) + .widgetURL(URL(string: "meshtastic:///node/\(context.attributes.name)")) } dynamicIsland: { context in DynamicIsland { @@ -95,7 +95,7 @@ struct WidgetsLiveActivity: Widget { .contentMargins(.trailing, 32, for: .expanded) .contentMargins([.leading, .top, .bottom], 6, for: .compactLeading) .contentMargins(.all, 6, for: .minimal) - .widgetURL(URL(string: "meshtastic://node/\(context.attributes.name)")) + .widgetURL(URL(string: "meshtastic:///node/\(context.attributes.name)")) } } }