diff --git a/Localizable.xcstrings b/Localizable.xcstrings index c7de260b..236ae149 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -307,6 +307,22 @@ }, "shouldTranslate" : false }, + "\"Disconnect Meshtastic\" — disconnect from the connected BLE node." : { + "comment" : "A description of how to use the \"Disconnect Node\" Siri shortcut.", + "isCommentAutoGenerated" : true + }, + "\"Send a Meshtastic direct message\" — send a private message to a node." : { + "comment" : "A description of how to send a direct message to a node using Siri.", + "isCommentAutoGenerated" : true + }, + "\"Send a Meshtastic group message\" — send a message to a mesh channel." : { + "comment" : "A description of how to send a group message using Siri.", + "isCommentAutoGenerated" : true + }, + "\"Shut down my Meshtastic node\" or \"Restart my Meshtastic node\"." : { + "comment" : "A description of how to use Siri to restart or shut down a node.", + "isCommentAutoGenerated" : true + }, "(Re)define PIN_GPS_EN for your board." : { "localizations" : { "da" : { @@ -2339,6 +2355,7 @@ } }, "🦕 End of life Version 🦖 ☄️" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4085,6 +4102,7 @@ } }, "Additional help" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -4136,6 +4154,10 @@ } } }, + "Additional Help" : { + "comment" : "A button that opens a link to the Meshtastic FAQ.", + "isCommentAutoGenerated" : true + }, "Address" : { "localizations" : { "da" : { @@ -7014,6 +7036,14 @@ } } }, + "Background Activity" : { + "comment" : "A title for a screen that describes the benefits of enabling background location tracking.", + "isCommentAutoGenerated" : true + }, + "Background Mesh Tracking" : { + "comment" : "A description of the background mesh tracking feature.", + "isCommentAutoGenerated" : true + }, "Backup" : { "localizations" : { "ja" : { @@ -7723,6 +7753,10 @@ } } }, + "Battery Usage" : { + "comment" : "A description of the battery usage of enabling background activity.", + "isCommentAutoGenerated" : true + }, "Baud" : { "localizations" : { "da" : { @@ -9479,6 +9513,10 @@ } } }, + "CarPlay Messaging" : { + "comment" : "A description of how to send a message to a mesh channel using CarPlay.", + "isCommentAutoGenerated" : true + }, "Categories" : { "localizations" : { "da" : { @@ -12465,6 +12503,10 @@ } } }, + "Configure Siri & Shortcuts" : { + "comment" : "A button that will open the app's settings to configure Siri and Shortcuts.", + "isCommentAutoGenerated" : true + }, "Confirm" : { "localizations" : { "da" : { @@ -12671,6 +12713,14 @@ } } }, + "Connect to nodes on your local Wi-Fi network." : { + "comment" : "A description of how to connect to nodes on your local Wi-Fi network.", + "isCommentAutoGenerated" : true + }, + "Connect to your Meshtastic node via Bluetooth Low Energy for the best messaging experience." : { + "comment" : "A description of the Bluetooth connectivity feature.", + "isCommentAutoGenerated" : true + }, "Connected" : { "localizations" : { "da" : { @@ -12753,6 +12803,10 @@ } } }, + "Connected firmware: **%@**" : { + "comment" : "A label displaying the firmware version of a device. The argument is the firmware version.", + "isCommentAutoGenerated" : true + }, "Connected Node %@" : { "localizations" : { "da" : { @@ -13239,6 +13293,14 @@ } } }, + "Continue" : { + "comment" : "A button that will continue to the next step in the onboarding process.", + "isCommentAutoGenerated" : true + }, + "Continuous Location Updates" : { + "comment" : "A description of the continuous location updates feature.", + "isCommentAutoGenerated" : true + }, "Control Type" : { "localizations" : { "da" : { @@ -14015,6 +14077,7 @@ } }, "Current Firmware Version: %@, Latest Firmware Version: %@" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -14072,6 +14135,16 @@ } } }, + "Current Firmware Version: %@, Minimum Required Version: %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Current Firmware Version: %1$@, Minimum Required Version: %2$@" + } + } + } + }, "Current: %lld" : { "localizations" : { "da" : { @@ -18853,6 +18926,10 @@ } } }, + "Enable Background Activity" : { + "comment" : "A toggle to enable or disable background activity.", + "isCommentAutoGenerated" : true + }, "Enable broadcasting device metrics to the mesh network. When disabled, metrics are only sent to connected clients." : { "localizations" : { "es" : { @@ -19387,6 +19464,10 @@ } } }, + "Enabling background activity may increase battery usage. You can toggle this at any time in the app settings." : { + "comment" : "A description of the battery usage of enabling background activity.", + "isCommentAutoGenerated" : true + }, "Enabling Ethernet will disable the bluetooth connection to the app." : { "localizations" : { "da" : { @@ -22158,6 +22239,7 @@ } }, "Firmware update docs" : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -22209,6 +22291,14 @@ } } }, + "Firmware Update Docs" : { + "comment" : "A link to the firmware update documentation.", + "isCommentAutoGenerated" : true + }, + "Firmware Update Required" : { + "comment" : "A title for a screen that displays a firmware update is required message.", + "isCommentAutoGenerated" : true + }, "Firmware Updates" : { "localizations" : { "da" : { @@ -26233,6 +26323,10 @@ } } }, + "How to Update" : { + "comment" : "A label displayed above the list of available firmware update options.", + "isCommentAutoGenerated" : true + }, "How to update Firmware" : { "localizations" : { "da" : { @@ -28134,6 +28228,10 @@ } } }, + "Keep the mesh map updated and send your position to the mesh even while using other apps." : { + "comment" : "A description of the benefits of continuous location updates.", + "isCommentAutoGenerated" : true + }, "Key" : { "localizations" : { "da" : { @@ -31439,7 +31537,12 @@ "comment" : "A description of the read-only mode feature in TAK Server.", "isCommentAutoGenerated" : true }, + "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app." : { + "comment" : "A description of how user data is used by Meshtastic.", + "isCommentAutoGenerated" : true + }, "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : { + "extractionState" : "stale", "localizations" : { "es" : { "stringUnit" : { @@ -31767,6 +31870,10 @@ } } }, + "Message Notifications" : { + "comment" : "A description of the message notifications feature.", + "isCommentAutoGenerated" : true + }, "Message received from the text message app." : { "extractionState" : "stale", "localizations" : { @@ -32286,6 +32393,10 @@ } } }, + "Minimum required: **%@**" : { + "comment" : "A label displaying the minimum required firmware version.", + "isCommentAutoGenerated" : true + }, "Minimum time between detection broadcasts" : { "extractionState" : "stale", "localizations" : { @@ -37046,6 +37157,10 @@ } } }, + "Open Web Flasher" : { + "comment" : "A button that opens the Web Flasher app.", + "isCommentAutoGenerated" : true + }, "Optimized for 2 color displays" : { "extractionState" : "stale", "localizations" : { @@ -42037,6 +42152,10 @@ } } }, + "Read and reply to Meshtastic channel and direct messages directly from your car's display using CarPlay." : { + "comment" : "A description of how to use CarPlay with Meshtastic.", + "isCommentAutoGenerated" : true + }, "Read-Only Mode" : { "comment" : "A toggle that allows the user to enable or disable read-only mode for the TAK server.", "isCommentAutoGenerated" : true @@ -42344,6 +42463,14 @@ } } }, + "Receive notifications for incoming messages and critical alerts even when the app is in the background." : { + "comment" : "A description of the notification feature.", + "isCommentAutoGenerated" : true + }, + "Receive position updates from other nodes and maintain an accurate picture of the mesh while in the background." : { + "comment" : "A description of the benefits of enabling background mesh tracking.", + "isCommentAutoGenerated" : true + }, "Received a negative acknowledgment" : { "extractionState" : "stale", "localizations" : { @@ -42613,6 +42740,10 @@ } } }, + "Recommended secure version: **%@**" : { + "comment" : "A label displaying the recommended secure version of the connected device.", + "isCommentAutoGenerated" : true + }, "Recording route" : { "localizations" : { "da" : { @@ -46664,6 +46795,10 @@ } } }, + "Security Advisory" : { + "comment" : "A title for a security advisory displayed in a card.", + "isCommentAutoGenerated" : true + }, "Security Config" : { "localizations" : { "da" : { @@ -46780,6 +46915,10 @@ } } }, + "Security Update Recommended" : { + "comment" : "A title for a view that warns the user that their device is running an outdated firmware version.", + "isCommentAutoGenerated" : true + }, "Select" : { "extractionState" : "stale", "localizations" : { @@ -47733,6 +47872,10 @@ } } }, + "Send and receive Meshtastic messages hands-free using Siri and CarPlay." : { + "comment" : "A description of how to use Siri and CarPlay with Meshtastic.", + "isCommentAutoGenerated" : true + }, "Send ASCII bell with alert message. Useful for triggering external notification on bell." : { "localizations" : { "da" : { @@ -50796,6 +50939,10 @@ } } }, + "Shut Down / Restart Node" : { + "comment" : "A Siri shortcut to restart or shut down a node.", + "isCommentAutoGenerated" : true + }, "Shut Down Node?" : { "localizations" : { "da" : { @@ -51152,6 +51299,14 @@ } } }, + "Siri & CarPlay" : { + "comment" : "A description of how to use Siri and CarPlay with Meshtastic.", + "isCommentAutoGenerated" : true + }, + "Siri, Shortcuts & CarPlay" : { + "comment" : "A label displayed above the Siri, Shortcuts & CarPlay onboarding view.", + "isCommentAutoGenerated" : true + }, "Six Hours" : { "extractionState" : "stale", "localizations" : { @@ -54143,7 +54298,12 @@ } } }, + "The Meshtastic Apple app requires firmware version %@ or later. Older firmware versions are no longer supported and may have compatibility issues or missing features." : { + "comment" : "A body text that explains that the app requires a certain version of the firmware.", + "isCommentAutoGenerated" : true + }, "The Meshtastic Apple apps support firmware version %@ and above." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -60283,6 +60443,7 @@ } }, "Version %@ includes substantial network optimizations and extensive changes to devices and client apps. Only nodes version %@ and above are supported." : { + "extractionState" : "stale", "localizations" : { "da" : { "stringUnit" : { @@ -61412,6 +61573,7 @@ } }, "Welcome to" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -61439,6 +61601,10 @@ } } }, + "Welcome to Meshtastic" : { + "comment" : "The title of the onboarding screen.", + "isCommentAutoGenerated" : true + }, "What does the lock mean?" : { "localizations" : { "da" : { @@ -62645,6 +62811,10 @@ "comment" : "A message displayed when a user successfully configures their primary channel for TAK. It instructs the user to share the QR code to invite TAK buddies.", "isCommentAutoGenerated" : true }, + "Your connected device is running firmware older than **%@**, which contains known security vulnerabilities. Updating your firmware is strongly recommended to protect your device and mesh network." : { + "comment" : "A body text that describes the security advisory.", + "isCommentAutoGenerated" : true + }, "Your current location will be set as the fixed position and broadcast over the mesh on the position interval." : { "localizations" : { "da" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 03da80a6..e9b19fea 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -86,6 +86,8 @@ 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 */; }; + AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00010022E2730EC0060000 /* ConnectViewTests.swift */; }; + DD9C70102E9F2A0000029299 /* DeviceOnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9C70112E9F2A0000029299 /* DeviceOnboardingTests.swift */; }; 2849A5E4CE9FDC1DB33DFA34 /* TAKConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01028778B8BFD81F7A039593 /* TAKConnection.swift */; }; 300424F80C4A445A0FBAE82D /* TAKMeshtasticBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87D006C85B250291D5925F30 /* TAKMeshtasticBridge.swift */; }; 3D3417B42E2730EC006A988B /* GeoJSONOverlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */; }; @@ -211,6 +213,7 @@ DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD798B062915928D005217CD /* ChannelMessageList.swift */; }; DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8169FE272476C700F4AB02 /* LogDocument.swift */; }; DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD836AE626F6B38600ABCC23 /* Connect.swift */; }; + DD4756AB2E9F1A0000029299 /* SecurityVersionNag.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4756AA2E9F1A0000029299 /* SecurityVersionNag.swift */; }; DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D409287F04F100BAEB7A /* InvalidVersion.swift */; }; DD86D40C287F401000BAEB7A /* SaveChannelQRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D40B287F401000BAEB7A /* SaveChannelQRCode.swift */; }; DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD86D40E2881BE4C00BAEB7A /* CsvDocument.swift */; }; @@ -443,6 +446,8 @@ 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 = ""; }; + AA00010022E2730EC0060000 /* ConnectViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewTests.swift; sourceTree = ""; }; + DD9C70112E9F2A0000029299 /* DeviceOnboardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceOnboardingTests.swift; sourceTree = ""; }; 2B37CCEE8B44A4BA123ED118 /* TAKServerManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TAKServerManager.swift; sourceTree = ""; }; 3D0A8ABAEF1E587683970927 /* EXICodec.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EXICodec.swift; sourceTree = ""; }; 3D3417B32E2730EC006A988B /* GeoJSONOverlayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoJSONOverlayManager.swift; sourceTree = ""; }; @@ -599,6 +604,7 @@ DD7E235F2C7AA3E50078ACDF /* MeshtasticDataModelV 43.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 43.xcdatamodel"; sourceTree = ""; }; DD8169FE272476C700F4AB02 /* LogDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogDocument.swift; sourceTree = ""; }; DD836AE626F6B38600ABCC23 /* Connect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connect.swift; sourceTree = ""; }; + DD4756AA2E9F1A0000029299 /* SecurityVersionNag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecurityVersionNag.swift; sourceTree = ""; }; DD86D409287F04F100BAEB7A /* InvalidVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvalidVersion.swift; sourceTree = ""; }; DD86D40B287F401000BAEB7A /* SaveChannelQRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveChannelQRCode.swift; sourceTree = ""; }; DD86D40E2881BE4C00BAEB7A /* CsvDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CsvDocument.swift; sourceTree = ""; }; @@ -951,6 +957,7 @@ isa = PBXGroup; children = ( AA00010022E2730EC0060000 /* ConnectViewTests.swift */, + DD9C70112E9F2A0000029299 /* DeviceOnboardingTests.swift */, 25F5D5D02C4375DF008036E3 /* RouterTests.swift */, AA000301CPTST000000000001 /* CarPlayTests.swift */, ); @@ -1063,7 +1070,8 @@ isa = PBXGroup; children = ( DD836AE626F6B38600ABCC23 /* Connect.swift */, - DD86D409287F04F100BAEB7A /* InvalidVersion.swift */, + DD4756AA2E9F1A0000029299 /* SecurityVersionNag.swift */, + DD86D409287F04F100BAEB7A /* InvalidVersion.swift */ ); path = Connect; sourceTree = ""; @@ -1733,6 +1741,7 @@ buildActionMask = 2147483647; files = ( AA0001012E2730EC00600001 /* ConnectViewTests.swift in Sources */, + DD9C70102E9F2A0000029299 /* DeviceOnboardingTests.swift in Sources */, 25F5D5D12C4375DF008036E3 /* RouterTests.swift in Sources */, AA000301CPTST000000000002 /* CarPlayTests.swift in Sources */, ); @@ -1764,6 +1773,7 @@ DDA9515C2BC6631200CEA535 /* TelemetryEnums.swift in Sources */, DDFEB3BB29900C1200EE7472 /* CurrentConditionsCompact.swift in Sources */, DD836AE726F6B38600ABCC23 /* Connect.swift in Sources */, + DD4756AB2E9F1A0000029299 /* SecurityVersionNag.swift in Sources */, D93069082B81DF040066FBC8 /* SaveConfigButton.swift in Sources */, DD5E523F298F5A9E00D21B61 /* AirQualityIndex.swift in Sources */, DDD5BB182C2F9C36007E03CA /* OSLogEntryLog.swift in Sources */, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift index 1e8f0ece..551ce3dd 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift @@ -116,7 +116,8 @@ class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate { // Constants let NONCE_ONLY_CONFIG = 69420 let NONCE_ONLY_DB = 69421 - let minimumVersion = "2.3.15" + let minimumVersion = "2.5.18" + let securityVersion = "2.6.0" // Global Objects // Chicken/Egg problem. Set in the App object immediately after diff --git a/Meshtastic/Extensions/View.swift b/Meshtastic/Extensions/View.swift index b28f5e8e..3009a089 100644 --- a/Meshtastic/Extensions/View.swift +++ b/Meshtastic/Extensions/View.swift @@ -47,6 +47,26 @@ extension View { } } + /// Standard capsule-shaped prominent button styling. + /// On iOS 26+ the button also receives a glass background effect. + @ViewBuilder + func capsuleButtonStyle() -> some View { + if #available(iOS 26.0, macOS 26.0, *) { + self + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + .glassEffect(in: .capsule) + } else { + self + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) + } + } + @ViewBuilder func glassButtonStyle() -> some View { if #available(iOS 26.0, macOS 26.0, *) { diff --git a/Meshtastic/Info.plist b/Meshtastic/Info.plist index 338dba77..827964be 100644 --- a/Meshtastic/Info.plist +++ b/Meshtastic/Info.plist @@ -119,6 +119,8 @@ We use the camera to share channels using a QR Code NSLocalNetworkUsageDescription We use local networking to connect to network-based nodes. + NSSiriUsageDescription + Siri and Shortcuts let you control Meshtastic hands-free — send messages, disconnect, restart, or shut down your node with your voice. NSLocationAlwaysAndWhenInUseUsageDescription We use your location to display it on the mesh map, show and filter by distance as well as to have GPS coordinates to send to the connected device. Route Recording uses location in the background. NSLocationAlwaysUsageDescription diff --git a/Meshtastic/Views/Connect/Connect.swift b/Meshtastic/Views/Connect/Connect.swift index 7e27221c..d72cc5c0 100644 --- a/Meshtastic/Views/Connect/Connect.swift +++ b/Meshtastic/Views/Connect/Connect.swift @@ -25,7 +25,10 @@ struct Connect: View { @State var node: NodeInfoEntity? @State var isUnsetRegion = false @State var invalidFirmwareVersion = false + @State var showSecurityVersionNag = false +#if !targetEnvironment(macCatalyst) @State var liveActivityStarted = false +#endif @ObservedObject var manualConnections = ManualConnectionList.shared var body: some View { @@ -347,6 +350,16 @@ struct Connect: View { // .onChange(of: accessoryManager) { // invalidFirmwareVersion = self.bleManager.invalidVersion // } + .sheet(isPresented: $invalidFirmwareVersion) { + InvalidVersion(minimumVersion: accessoryManager.minimumVersion, version: accessoryManager.activeConnection?.device.firmwareVersion ?? "?.?.?") + .presentationDetents([.large]) + .presentationDragIndicator(.automatic) + } + .sheet(isPresented: $showSecurityVersionNag) { + SecurityVersionNag(minimumSecureVersion: accessoryManager.securityVersion, version: accessoryManager.activeConnection?.device.firmwareVersion ?? "?.?.?") + .presentationDetents([.large]) + .presentationDragIndicator(.automatic) + } .onChange(of: self.accessoryManager.state) { _, state in if let deviceNum = accessoryManager.activeDeviceNum, UserDefaults.preferredPeripheralId.count > 0 && state == .subscribed { @@ -364,6 +377,11 @@ struct Connect: View { } catch { Logger.data.error("💥 Error fetching node info: \(error.localizedDescription, privacy: .public)") } + // Check firmware version on connection + let meetsMinimumVersion = accessoryManager.checkIsVersionSupported(forVersion: accessoryManager.minimumVersion) + let meetsSecurityVersion = accessoryManager.checkIsVersionSupported(forVersion: accessoryManager.securityVersion) + invalidFirmwareVersion = !meetsMinimumVersion + showSecurityVersionNag = meetsMinimumVersion && !meetsSecurityVersion } } } diff --git a/Meshtastic/Views/Connect/InvalidVersion.swift b/Meshtastic/Views/Connect/InvalidVersion.swift index d6030139..2b1227c1 100644 --- a/Meshtastic/Views/Connect/InvalidVersion.swift +++ b/Meshtastic/Views/Connect/InvalidVersion.swift @@ -10,55 +10,97 @@ struct InvalidVersion: View { @Environment(\.dismiss) private var dismiss - @State var minimumVersion = "" - @State var version = "" + let minimumVersion: String + let version: String var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + .padding(.top, 40) - VStack { - - Text("Update Your Firmware") - .font(.largeTitle) - .foregroundColor(.orange) - - Divider() - VStack { - Text("The Meshtastic Apple apps support firmware version \(minimumVersion) and above.") - .font(.title2) - .padding(.bottom) - Link("Firmware update docs", destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/")!) - .font(.title) - .padding() - Link("Additional help", destination: URL(string: "https://meshtastic.org/docs/faq")!) - .font(.title) - .padding() - } - .padding() - Divider() - .padding(.top) - VStack { - Text("🦕 End of life Version 🦖 ☄️") - .font(.title3) - .foregroundColor(.orange) - .padding(.bottom) - Text("Version \(minimumVersion) includes substantial network optimizations and extensive changes to devices and client apps. Only nodes version \(minimumVersion) and above are supported.") - .font(.callout) - .padding([.leading, .trailing, .bottom]) - - #if targetEnvironment(macCatalyst) - Button { - dismiss() - } label: { - Label("Close", systemImage: "xmark") + Text("Firmware Update Required") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 8) { + if !version.isEmpty { + Label { + Text("Connected firmware: **\(version)**") + } icon: { + Image(systemName: "wifi.slash") + .foregroundColor(.red) + } + .font(.body) + } + Label { + Text("Minimum required: **\(minimumVersion)**") + } icon: { + Image(systemName: "checkmark.shield.fill") + .foregroundColor(.green) + } + .font(.body) } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .controlSize(.large) .padding() - #endif + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) - }.padding() + Text("The Meshtastic Apple app requires firmware version \(minimumVersion) or later. Older firmware versions are no longer supported and may have compatibility issues or missing features.") + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 12) { + Text("How to Update") + .font(.headline) + Link(destination: URL(string: "https://flasher.meshtastic.org")!) { + Label("Open Web Flasher", systemImage: "bolt.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .buttonBorderShape(.capsule) + Link(destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/")!) { + Label("Firmware Update Docs", systemImage: "book.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .buttonBorderShape(.capsule) + Link(destination: URL(string: "https://meshtastic.org/docs/faq")!) { + Label("Additional Help", systemImage: "questionmark.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .buttonBorderShape(.capsule) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + .padding(.horizontal) + } + .padding(.bottom, 20) + } + + #if targetEnvironment(macCatalyst) + Button { + dismiss() + } label: { + Label("Close", systemImage: "xmark") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + #endif } } } diff --git a/Meshtastic/Views/Connect/SecurityVersionNag.swift b/Meshtastic/Views/Connect/SecurityVersionNag.swift new file mode 100644 index 00000000..1327b674 --- /dev/null +++ b/Meshtastic/Views/Connect/SecurityVersionNag.swift @@ -0,0 +1,103 @@ +// +// SecurityVersionNag.swift +// Meshtastic +// +// Copyright(c) Garth Vander Houwen 2024. +// +import SwiftUI + +struct SecurityVersionNag: View { + + @Environment(\.dismiss) private var dismiss + + let minimumSecureVersion: String + let version: String + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 20) { + Image(systemName: "shield.slash.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + .padding(.top, 40) + + Text("Security Update Recommended") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 8) { + if !version.isEmpty { + Label { + Text("Connected firmware: **\(version)**") + } icon: { + Image(systemName: "wifi.exclamationmark") + .foregroundColor(.orange) + } + .font(.body) + } + Label { + Text("Recommended secure version: **\(minimumSecureVersion)**") + } icon: { + Image(systemName: "checkmark.shield.fill") + .foregroundColor(.green) + } + .font(.body) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + VStack(alignment: .leading, spacing: 12) { + Text("Security Advisory") + .font(.headline) + Text("Your connected device is running firmware older than **\(minimumSecureVersion)**, which contains known security vulnerabilities. Updating your firmware is strongly recommended to protect your device and mesh network.") + .font(.body) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 12) { + Text("How to Update") + .font(.headline) + Link(destination: URL(string: "https://flasher.meshtastic.org")!) { + Label("Open Web Flasher", systemImage: "bolt.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .buttonBorderShape(.capsule) + Link(destination: URL(string: "https://meshtastic.org/docs/getting-started/flashing-firmware/")!) { + Label("Firmware Update Docs", systemImage: "book.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.regular) + .buttonBorderShape(.capsule) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + .padding(.horizontal) + } + .padding(.bottom, 20) + } + + Button { + dismiss() + } label: { + Text("Dismiss") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + } + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift index fd142007..2d04e861 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/MapLegend.swift @@ -61,6 +61,18 @@ struct MapLegend: View { .navigationTitle("Map Legend") .navigationBarTitleDisplayMode(.inline) } +#if targetEnvironment(macCatalyst) + Spacer() + Button { + dismiss() + } label: { + Label("Close", systemImage: "xmark") + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding(.bottom) +#endif } // MARK: - Sections diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift index f0c9f1f1..66eb6e3c 100644 --- a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -1,4 +1,5 @@ import CoreBluetooth +import Intents import OSLog import SwiftUI import Foundation @@ -8,11 +9,14 @@ struct DeviceOnboarding: View { enum SetupGuide: Hashable { case notifications case location + case backgroundActivity case localNetwork case bluetooth + case siri } @EnvironmentObject var accessoryManager: AccessoryManager + @ObservedObject private var locationsHandler: LocationsHandler = .shared @State var navigationPath: [SetupGuide] = [] @State var locationStatus = LocationsHandler.shared.manager.authorizationStatus @AppStorage("provideLocation") private var provideLocation: Bool = false @@ -21,25 +25,20 @@ struct DeviceOnboarding: View { /// The Title View var title: some View { VStack { - Text("Welcome to") - .font(.title2.bold()) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - Text("Meshtastic") - .font(.largeTitle.bold()) + Text("Welcome to Meshtastic") + .font(.title.bold()) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) } } var welcomeView: some View { - VStack { + VStack(spacing: 0) { ScrollView(.vertical) { VStack { // Title title .padding(.top) - // Onboarding VStack(alignment: .leading, spacing: 16) { makeRow( icon: "antenna.radiowaves.left.and.right", @@ -59,14 +58,34 @@ struct DeviceOnboarding: View { makeRow( icon: "person.2.shield", title: String(localized: "User Privacy"), - subtitle: String(localized: "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings.") + subtitle: String(localized: "Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app.") + ) + makeRow( + icon: "bell.badge", + title: String(localized: "Message Notifications"), + subtitle: String(localized: "Receive notifications for incoming messages and critical alerts even when the app is in the background.") + ) + makeRow( + icon: "custom.bluetooth", + title: String(localized: "Bluetooth Connectivity"), + subtitle: String(localized: "Connect to your Meshtastic node via Bluetooth Low Energy for the best messaging experience.") + ) + makeRow( + icon: "network", + title: String(localized: "Local Network Access"), + subtitle: String(localized: "Connect to nodes on your local Wi-Fi network.") + ) + makeRow( + icon: "car.fill", + title: String(localized: "Siri & CarPlay"), + subtitle: String(localized: "Send and receive Meshtastic messages hands-free using Siri and CarPlay.") ) } - .padding() + .padding(.horizontal) + .padding(.bottom) } - .interactiveDismissDisabled() } - Spacer() + .interactiveDismissDisabled() Button { Task { await goToNextStep(after: nil) @@ -75,10 +94,7 @@ struct DeviceOnboarding: View { Text("Get started") .frame(maxWidth: .infinity) } - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -133,10 +149,7 @@ struct DeviceOnboarding: View { Text("Configure notification permissions") .frame(maxWidth: .infinity) } - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -202,10 +215,64 @@ struct DeviceOnboarding: View { .frame(maxWidth: .infinity) } .padding() - .buttonBorderShape(.capsule) - .controlSize(.large) + .capsuleButtonStyle() + } + } + + var backgroundActivityView: some View { + VStack { + ScrollView(.vertical) { + VStack { + Text("Background Activity") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + VStack(alignment: .leading, spacing: 16) { + Text(createBackgroundActivityString()) + .font(.body.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + makeRow( + icon: "location.fill", + title: String(localized: "Continuous Location Updates"), + subtitle: String(localized: "Keep the mesh map updated and send your position to the mesh even while using other apps.") + ) + makeRow( + icon: "antenna.radiowaves.left.and.right", + title: String(localized: "Background Mesh Tracking"), + subtitle: String(localized: "Receive position updates from other nodes and maintain an accurate picture of the mesh while in the background.") + ) + makeRow( + icon: "battery.100.bolt", + title: String(localized: "Battery Usage"), + subtitle: String(localized: "Enabling background activity may increase battery usage. You can toggle this at any time in the app settings.") + ) + Toggle(isOn: $locationsHandler.backgroundActivity) { + Label { + Text("Enable Background Activity") + } icon: { + Image(systemName: "location.circle") + } + } + .fixedSize() + .scaleEffect(0.85) + .padding(.leading, 52) + .tint(.accentColor) + } + .padding() + } + Spacer() + Button { + Task { + await goToNextStep(after: .backgroundActivity) + } + } label: { + Text("Continue") + .frame(maxWidth: .infinity) + } .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -252,10 +319,7 @@ struct DeviceOnboarding: View { .frame(maxWidth: .infinity) } .padding() - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -297,10 +361,64 @@ struct DeviceOnboarding: View { .frame(maxWidth: .infinity) } .padding() - .buttonBorderShape(.capsule) - .controlSize(.large) + .capsuleButtonStyle() + } + } + + var siriView: some View { + VStack { + ScrollView(.vertical) { + VStack { + Text("Siri, Shortcuts & CarPlay") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + VStack(alignment: .leading, spacing: 16) { + Text(createSiriString()) + .font(.body.bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + makeRow( + icon: "car.fill", + title: String(localized: "CarPlay Messaging"), + subtitle: String(localized: "Read and reply to Meshtastic channel and direct messages directly from your car's display using CarPlay.") + ) + makeRow( + icon: "message", + title: String(localized: "Send a Group Message"), + subtitle: String(localized: "\"Send a Meshtastic group message\" — send a message to a mesh channel.") + ) + makeRow( + icon: "bubble", + title: String(localized: "Send a Direct Message"), + subtitle: String(localized: "\"Send a Meshtastic direct message\" — send a private message to a node.") + ) + makeRow( + icon: "power", + title: String(localized: "Shut Down / Restart Node"), + subtitle: String(localized: "\"Shut down my Meshtastic node\" or \"Restart my Meshtastic node\".") + ) + makeRow( + icon: "antenna.radiowaves.left.and.right.slash", + title: String(localized: "Disconnect Node"), + subtitle: String(localized: "\"Disconnect Meshtastic\" — disconnect from the connected BLE node.") + ) + } + .padding() + } + Spacer() + Button { + Task { + await requestSiriPermissions() + await goToNextStep(after: .siri) + } + } label: { + Text("Configure Siri & Shortcuts") + .frame(maxWidth: .infinity) + } .padding() - .buttonStyle(.borderedProminent) + .capsuleButtonStyle() } } @@ -313,16 +431,50 @@ struct DeviceOnboarding: View { notificationView case .location: locationView + case .backgroundActivity: + backgroundActivityView case .bluetooth: bluetoothView case .localNetwork: localNetworkView + case .siri: + siriView } } } .toolbar(.hidden) } - + + @ViewBuilder + func makeCompactRow(icon: String, title: String, subtitle: String) -> some View { + HStack(alignment: .center, spacing: 12) { + Group { + if icon.starts(with: "custom.") { + Image(icon) + .resizable() + .symbolRenderingMode(.multicolor) + } else { + Image(systemName: icon) + .resizable() + .symbolRenderingMode(.multicolor) + } + } + .aspectRatio(contentMode: .fit) + .frame(width: 28, height: 28) + .padding(.leading, 4) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.footnote.weight(.semibold)) + .foregroundColor(.primary) + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .combine) + } + @ViewBuilder func makeRow( icon: String, @@ -351,11 +503,11 @@ struct DeviceOnboarding: View { } VStack(alignment: .leading) { Text(title) - .font(.subheadline.weight(.semibold)) + .font(.footnote.weight(.semibold)) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) Text(subtitle) - .font(.subheadline) + .font(.footnote) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) }.multilineTextAlignment(.leading) @@ -381,18 +533,31 @@ struct DeviceOnboarding: View { } case .location: locationStatus = LocationsHandler.shared.manager.authorizationStatus - if locationStatus != .notDetermined && locationStatus != .restricted { - navigationPath.append(.localNetwork) + if locationStatus == .authorizedWhenInUse || locationStatus == .authorizedAlways { + navigationPath.append(.backgroundActivity) } + case .backgroundActivity: + navigationPath.append(.localNetwork) case .localNetwork: navigationPath.append(.bluetooth) case .bluetooth: + navigationPath.append(.siri) + case .siri: dismiss() } } // MARK: Formatting + func createBackgroundActivityString() -> AttributedString { + var fullText = AttributedString("Meshtastic can track your location in the background to keep the mesh map updated and send your position to the mesh even when the app is not in the foreground. You can update this setting at any time from settings.") + if let range = fullText.range(of: "settings") { + fullText[range].link = URL(string: UIApplication.openSettingsURLString)! + fullText[range].foregroundColor = .blue + } + return fullText + } + func createLocationString() -> AttributedString { var fullText = AttributedString(localized: "Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from settings.") if let range = fullText.range(of: String(localized: "settings")) { @@ -420,6 +585,15 @@ struct DeviceOnboarding: View { return fullText } + func createSiriString() -> AttributedString { + var fullText = AttributedString("Meshtastic supports Siri, Shortcuts, and CarPlay so you can send and receive messages hands-free. You can update Siri permissions at any time from settings.") + if let range = fullText.range(of: "settings") { + fullText[range].link = URL(string: UIApplication.openSettingsURLString)! + fullText[range].foregroundColor = .blue + } + return fullText + } + // MARK: Permission Checks func requestNotificationsPermissions() async { let center = UNUserNotificationCenter.current() @@ -452,6 +626,22 @@ struct DeviceOnboarding: View { func requestBluetoothPermissions() async { _ = await BluetoothAuthorizationHelper.requestBluetoothAuthorization() } + + func requestSiriPermissions() async { + await withCheckedContinuation { continuation in + INPreferences.requestSiriAuthorization { status in + switch status { + case .authorized: + Logger.services.info("Siri permissions are enabled") + case .denied: + Logger.services.info("Siri permissions denied") + default: + Logger.services.info("Siri permissions status: \(status.rawValue)") + } + continuation.resume() + } + } + } } diff --git a/Meshtastic/Views/Settings/Firmware.swift b/Meshtastic/Views/Settings/Firmware.swift index 3869d145..d909d956 100644 --- a/Meshtastic/Views/Settings/Firmware.swift +++ b/Meshtastic/Views/Settings/Firmware.swift @@ -13,14 +13,13 @@ struct Firmware: View { @Environment(\.managedObjectContext) var context @EnvironmentObject var accessoryManager: AccessoryManager var node: NodeInfoEntity? - @State var minimumVersion = "2.5.4" @State var version = "" @State private var currentDevice: DeviceHardware? @State private var latestStable: FirmwareRelease? @State private var latestAlpha: FirmwareRelease? var body: some View { - let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumVersion) + let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: accessoryManager.minimumVersion) let connectedVersion = accessoryManager.activeConnection?.device.firmwareVersion ?? "Unknown" ScrollView { VStack(alignment: .leading) { @@ -63,7 +62,7 @@ struct Firmware: View { .foregroundStyle(.red) .font(.title2) .padding(.bottom) - Text("Current Firmware Version: \(connectedVersion), Latest Firmware Version: \(minimumVersion)") + Text("Current Firmware Version: \(connectedVersion), Minimum Required Version: \(accessoryManager.minimumVersion)") .fixedSize(horizontal: false, vertical: true) .font(.title3) .padding(.bottom) diff --git a/MeshtasticTests/ConnectViewTests.swift b/MeshtasticTests/ConnectViewTests.swift index 450591ad..c2d1d5e1 100644 --- a/MeshtasticTests/ConnectViewTests.swift +++ b/MeshtasticTests/ConnectViewTests.swift @@ -364,7 +364,7 @@ struct InvalidVersionTests { } @Test func viewCreationWithEmptyVersions() { - let view = InvalidVersion() + let view = InvalidVersion(minimumVersion: "", version: "") #expect(view.minimumVersion == "") #expect(view.version == "") } diff --git a/MeshtasticTests/DeviceOnboardingTests.swift b/MeshtasticTests/DeviceOnboardingTests.swift new file mode 100644 index 00000000..4831c87c --- /dev/null +++ b/MeshtasticTests/DeviceOnboardingTests.swift @@ -0,0 +1,181 @@ +// +// DeviceOnboardingTests.swift +// MeshtasticTests +// +// Copyright(c) Garth Vander Houwen 2026. +// + +import Foundation +import Testing +@testable import Meshtastic + +// MARK: - SetupGuide Enum + +@Suite("DeviceOnboarding.SetupGuide") +struct SetupGuideTests { + + @Test func allCasesExist() { + let cases: [DeviceOnboarding.SetupGuide] = [ + .notifications, .location, .backgroundActivity, + .localNetwork, .bluetooth, .siri + ] + #expect(cases.count == 6) + } + + @Test func isHashable() { + var seen = Set() + seen.insert(.notifications) + seen.insert(.notifications) // duplicate should not grow set + seen.insert(.siri) + #expect(seen.count == 2) + } + + @Test func equality() { + #expect(DeviceOnboarding.SetupGuide.bluetooth == .bluetooth) + #expect(DeviceOnboarding.SetupGuide.notifications != .siri) + #expect(DeviceOnboarding.SetupGuide.location != .backgroundActivity) + } + + @Test func allCasesAreUnique() { + let cases: [DeviceOnboarding.SetupGuide] = [ + .notifications, .location, .backgroundActivity, + .localNetwork, .bluetooth, .siri + ] + let unique = Set(cases) + #expect(unique.count == cases.count) + } +} + +// MARK: - Attributed String Formatters + +@Suite("DeviceOnboarding string formatters") +struct OnboardingStringFormatterTests { + + let view = DeviceOnboarding() + + // Helpers + private func hasSettingsLink(_ string: AttributedString) -> Bool { + guard let range = string.range(of: "settings") else { return false } + return string[range].link != nil + } + + private func settingsLinkURL(_ string: AttributedString) -> URL? { + guard let range = string.range(of: "settings") else { return nil } + return string[range].link + } + + @Test func backgroundActivityStringContainsText() { + let string = view.createBackgroundActivityString() + #expect(string.description.contains("background")) + #expect(string.description.contains("settings")) + } + + @Test func backgroundActivityStringHasSettingsLink() { + let string = view.createBackgroundActivityString() + #expect(hasSettingsLink(string)) + } + + @Test func backgroundActivitySettingsLinkIsAppSettings() { + let string = view.createBackgroundActivityString() + let url = settingsLinkURL(string) + #expect(url?.scheme == "app-settings" || url?.absoluteString.contains("settings") == true) + } + + @Test func locationStringContainsText() { + let string = view.createLocationString() + #expect(string.description.contains("location")) + #expect(string.description.contains("settings")) + } + + @Test func locationStringHasSettingsLink() { + let string = view.createLocationString() + #expect(hasSettingsLink(string)) + } + + @Test func localNetworkStringContainsText() { + let string = view.createLocalNetworkString() + #expect(string.description.contains("local network") || string.description.contains("TCP")) + #expect(string.description.contains("settings")) + } + + @Test func localNetworkStringHasSettingsLink() { + let string = view.createLocalNetworkString() + #expect(hasSettingsLink(string)) + } + + @Test func bluetoothStringContainsText() { + let string = view.createBluetoothString() + #expect(string.description.contains("Bluetooth") || string.description.contains("BLE")) + #expect(string.description.contains("settings")) + } + + @Test func bluetoothStringHasSettingsLink() { + let string = view.createBluetoothString() + #expect(hasSettingsLink(string)) + } + + @Test func siriStringContainsCarPlay() { + let string = view.createSiriString() + #expect(string.description.contains("CarPlay")) + } + + @Test func siriStringContainsSiri() { + let string = view.createSiriString() + #expect(string.description.contains("Siri")) + } + + @Test func siriStringHasSettingsLink() { + let string = view.createSiriString() + #expect(hasSettingsLink(string)) + } + + @Test func allStringsHaveSettingsLinks() { + let strings = [ + view.createBackgroundActivityString(), + view.createLocationString(), + view.createLocalNetworkString(), + view.createBluetoothString(), + view.createSiriString() + ] + for string in strings { + #expect(hasSettingsLink(string), "Expected 'settings' link in: \(string)") + } + } +} + +// MARK: - Navigation Flow + +@Suite("DeviceOnboarding navigation") +struct OnboardingNavigationTests { + + @Test func backgroundActivityAlwaysGoesToLocalNetwork() async { + let view = DeviceOnboarding() + await view.goToNextStep(after: .backgroundActivity) + #expect(view.navigationPath == [.localNetwork]) + } + + @Test func localNetworkAlwaysGoesToBluetooth() async { + let view = DeviceOnboarding() + await view.goToNextStep(after: .localNetwork) + #expect(view.navigationPath == [.bluetooth]) + } + + @Test func bluetoothAlwaysGoesToSiri() async { + let view = DeviceOnboarding() + await view.goToNextStep(after: .bluetooth) + #expect(view.navigationPath == [.siri]) + } + + @Test func navigationPathStartsEmpty() { + let view = DeviceOnboarding() + #expect(view.navigationPath.isEmpty) + } + + @Test func deterministicStepsAppendInOrder() async { + let view = DeviceOnboarding() + await view.goToNextStep(after: .backgroundActivity) + await view.goToNextStep(after: .localNetwork) + await view.goToNextStep(after: .bluetooth) + #expect(view.navigationPath == [.localNetwork, .bluetooth, .siri]) + } +}