mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge origin/2.7.10 into firmware-updates - resolve conflicts (CarPlay, onboarding, map cache, DM crash fix)
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
commit
7718132c6f
49 changed files with 6415 additions and 427 deletions
|
|
@ -229,6 +229,22 @@
|
|||
"comment": "A button that initiates",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"\"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
|
||||
},
|
||||
"%@": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
|
|
@ -4031,7 +4047,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Additional Help": {
|
||||
"comment": "A button that opens a link to the Meshtastic FAQ.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Additional help": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -7105,6 +7126,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": {
|
||||
|
|
@ -7814,6 +7843,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Battery Usage": {
|
||||
"comment": "A description of the battery usage of enabling background activity.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Baud": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
|
|
@ -9450,6 +9483,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"CarPlay Messaging": {
|
||||
"comment": "A description of how to send a message to a mesh channel using CarPlay.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Carousel Interval": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
|
|
@ -12060,7 +12097,6 @@
|
|||
}
|
||||
},
|
||||
"Community Support": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -12407,6 +12443,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Configure Siri & Shortcuts": {
|
||||
"comment": "A button that will open the app's settings to configure Siri and Shortcuts.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Configure notification permissions": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
|
@ -12641,6 +12681,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": {
|
||||
|
|
@ -12815,6 +12863,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Connected firmware: **%@**": {
|
||||
"comment": "A label displaying the firmware version of a device. The argument is the firmware version.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Connecting . .": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
|
|
@ -13001,6 +13053,64 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Connection Attempt %lld of 10": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tilslutningsforsøg %lld af 10"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Verbindungsversuch %lld von 10"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Intento de conexión %lld de 10"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Tentativo di connessione %lld di 10"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "接続試行 %lld / 10"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Количество попыток подключения, %lld из 10"
|
||||
}
|
||||
},
|
||||
"sr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Покушај повезивања %lld од 10"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "连接尝试 %lld,共 10 次"
|
||||
}
|
||||
},
|
||||
"zh-Hant-TW": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "嘗試連接 %lld / 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Connection Name": {
|
||||
"localizations": {
|
||||
"es": {
|
||||
|
|
@ -13209,6 +13319,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": {
|
||||
|
|
@ -13926,7 +14044,6 @@
|
|||
},
|
||||
"Current Firmware Version": {},
|
||||
"Current Firmware Version: %@": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -14043,6 +14160,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": {
|
||||
|
|
@ -14096,7 +14223,6 @@
|
|||
}
|
||||
},
|
||||
"Currently the recommended way to update ESP32 devices is using the web flasher on a desktop computer from a chrome based browser. It does not work on mobile devices or over BLE.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -18194,7 +18320,6 @@
|
|||
},
|
||||
"Download TAK Server Data Package": {},
|
||||
"Drag & Drop Firmware Update": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -18247,7 +18372,6 @@
|
|||
}
|
||||
},
|
||||
"Drag & Drop Firmware Update Documentation": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -18300,7 +18424,6 @@
|
|||
}
|
||||
},
|
||||
"Drag & Drop is the recommended way to update firmware for NRF devices. If your iPhone or iPad is USB-C it will work with your regular USB-C charging cable, for lightning devices you need the Apple Lightning to USB camera adaptor.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -18458,7 +18581,6 @@
|
|||
}
|
||||
},
|
||||
"ESP 32 OTA update is a work in progress, click the button below to send your device a reboot into ota admin message.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -18515,7 +18637,6 @@
|
|||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"ESP32 Device Firmware Update": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -18958,6 +19079,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Enable Background Activity": {
|
||||
"comment": "A toggle to enable or disable background activity.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Enable Location Sharing": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
|
|
@ -19558,6 +19683,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
|
||||
},
|
||||
"Encoder Press Event": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
|
|
@ -19786,7 +19915,6 @@
|
|||
}
|
||||
},
|
||||
"Enter DFU Mode": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -22107,6 +22235,14 @@
|
|||
"comment": "A section header that lists available firmware releases.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"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": {
|
||||
|
|
@ -22252,6 +22388,7 @@
|
|||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Firmware update docs": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -23724,7 +23861,6 @@
|
|||
}
|
||||
},
|
||||
"Full Support": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -24320,7 +24456,6 @@
|
|||
}
|
||||
},
|
||||
"Get NRF DFU from the App Store": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -24505,7 +24640,6 @@
|
|||
}
|
||||
},
|
||||
"Get the latest stable firmware": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -26192,8 +26326,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"How to Update": {
|
||||
"comment": "A label displayed above the list of available firmware update options.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"How to update Firmware": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -26893,7 +27030,6 @@
|
|||
}
|
||||
},
|
||||
"If it is hard to access your device's reset button enter DFU mode here.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -28101,6 +28237,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": {
|
||||
|
|
@ -31760,7 +31900,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
|
@ -31984,6 +32129,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Message Notifications": {
|
||||
"comment": "A description of the message notifications feature.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Message Size": {
|
||||
"comment": "VoiceOver label for message size",
|
||||
"localizations": {
|
||||
|
|
@ -32559,6 +32708,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Minimum required: **%@**": {
|
||||
"comment": "A label displaying the minimum required firmware version.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Minimum time between detection broadcasts": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
|
|
@ -34109,7 +34262,6 @@
|
|||
}
|
||||
},
|
||||
"Newer firmware is available": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -36415,7 +36567,6 @@
|
|||
}
|
||||
},
|
||||
"OTA Updates are not supported on this NRF Device.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -36468,7 +36619,6 @@
|
|||
}
|
||||
},
|
||||
"OTA Updates are not supported on your platform.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -37193,7 +37343,7 @@
|
|||
}
|
||||
},
|
||||
"Open Web Flasher": {
|
||||
"comment": "A link label that says \"Open Web Flasher\" and has an arrow icon pointing to it.",
|
||||
"comment": "A button that opens the Web Flasher app.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Optimized for 2 color displays": {
|
||||
|
|
@ -42276,6 +42426,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
|
||||
|
|
@ -42587,6 +42741,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 Ack": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
|
|
@ -42856,6 +43018,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Recommended secure version: **%@**": {
|
||||
"comment": "A label displaying the recommended secure version of the connected device.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Recording route": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
|
|
@ -46863,6 +47029,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Security Advisory": {
|
||||
"comment": "A title for a security advisory displayed in a card.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Security Config": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
|
|
@ -46979,6 +47149,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": {
|
||||
|
|
@ -47797,7 +47971,6 @@
|
|||
}
|
||||
},
|
||||
"Send Reboot OTA": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -48205,6 +48378,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
|
||||
},
|
||||
"Sender Interval": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
|
|
@ -50960,6 +51137,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Shut Down / Restart Node": {
|
||||
"comment": "A Siri shortcut to restart or shut down a node.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"Shut Down Node?": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
|
|
@ -51316,6 +51497,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": {
|
||||
|
|
@ -53867,7 +54056,12 @@
|
|||
"comment": "A footnote explaining that the Web Flasher does not support updating on this device or over USB or BLE.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"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": {
|
||||
|
|
@ -60229,6 +60423,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": {
|
||||
|
|
@ -61214,7 +61409,6 @@
|
|||
}
|
||||
},
|
||||
"Web Flasher": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -61359,6 +61553,7 @@
|
|||
}
|
||||
},
|
||||
"Welcome to": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
|
|
@ -61386,6 +61581,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Welcome to Meshtastic": {
|
||||
"comment": "The title of the onboarding screen.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"What does the lock mean?": {
|
||||
"localizations": {
|
||||
"da": {
|
||||
|
|
@ -62354,7 +62553,6 @@
|
|||
}
|
||||
},
|
||||
"You can also update your Meshtastic device over bluetooth using the Nordic DFU app.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -62493,7 +62691,6 @@
|
|||
}
|
||||
},
|
||||
"Your Firmware is up to date": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
@ -62601,6 +62798,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": {
|
||||
|
|
@ -63284,6 +63485,7 @@
|
|||
}
|
||||
},
|
||||
"🦕 End of life Version 🦖 ☄️": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"originHash" : "943f1047f8d99b0600f1c91f14d7bf4808ab1caf172ae4d7f3ebea325c27437f",
|
||||
"originHash" : "7d747a138ea225de00b815c2d9ed46c704c081d98cc8d1018c8d11cb91f39bc4",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "cocoamqtt",
|
||||
|
|
@ -19,15 +19,6 @@
|
|||
"version" : "3.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "ios-dfu-library",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/NordicSemiconductor/IOS-DFU-Library",
|
||||
"state" : {
|
||||
"revision" : "4773d7eed944684dfdd177a4a91af8a89ebbaac8",
|
||||
"version" : "4.16.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mqttcocoaasyncsocket",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
@ -72,24 +63,6 @@
|
|||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftdraw",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swhitty/SwiftDraw",
|
||||
"state" : {
|
||||
"revision" : "17d55c17540f3eb10685058e803d7ae73d9bf9d3",
|
||||
"version" : "0.25.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "zipfoundation",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/weichsel/ZIPFoundation",
|
||||
"state" : {
|
||||
"revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0",
|
||||
"version" : "0.9.19"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"originHash" : "2ef98d89234a9436246b308320a5af24fe6a09930ba8db91f4e0a48cc09dcd60",
|
||||
"originHash" : "25240dd07109fa832be10093f5d97529f872f18e8d9df6468e5e4212bc0b487e",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "cocoamqtt",
|
||||
|
|
@ -19,15 +19,6 @@
|
|||
"version" : "3.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "ios-dfu-library",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/NordicSemiconductor/IOS-DFU-Library",
|
||||
"state" : {
|
||||
"revision" : "4773d7eed944684dfdd177a4a91af8a89ebbaac8",
|
||||
"version" : "4.16.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mqttcocoaasyncsocket",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
@ -72,24 +63,6 @@
|
|||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftdraw",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swhitty/SwiftDraw",
|
||||
"state" : {
|
||||
"revision" : "17d55c17540f3eb10685058e803d7ae73d9bf9d3",
|
||||
"version" : "0.25.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "zipfoundation",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/weichsel/ZIPFoundation",
|
||||
"state" : {
|
||||
"revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0",
|
||||
"version" : "0.9.19"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
|
|
|||
|
|
@ -376,7 +376,10 @@ extension AccessoryManager {
|
|||
do {
|
||||
try context.save()
|
||||
Logger.data.info("💾 Saved a new sent message from \(self.activeDeviceNum?.toHex() ?? "0", privacy: .public) to \(toUserNum.toHex(), privacy: .public)")
|
||||
|
||||
// Donate outgoing message to SiriKit for CarPlay
|
||||
if !isEmoji {
|
||||
CarPlayIntentDonation.donateOutgoingMessage(content: message, toUserNum: toUserNum, channel: channel)
|
||||
}
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
142
Meshtastic/CarPlay/CarPlayIntentDonation.swift
Normal file
142
Meshtastic/CarPlay/CarPlayIntentDonation.swift
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
//
|
||||
// CarPlayIntentDonation.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Copyright(c) Garth Vander Houwen 4/16/26.
|
||||
//
|
||||
// Donates SiriKit interactions when messages are received so that
|
||||
// conversations appear in CarPlay's messaging interface and Siri
|
||||
// can read them aloud.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Intents
|
||||
import OSLog
|
||||
|
||||
enum CarPlayIntentDonation {
|
||||
|
||||
/// Donates an incoming message interaction so it appears in CarPlay Messages.
|
||||
/// Call this after saving a new `MessageEntity` to Core Data.
|
||||
static func donateReceivedMessage(_ message: MessageEntity) {
|
||||
guard let fromUser = message.fromUser else { return }
|
||||
guard !message.isEmoji, !message.admin else { return }
|
||||
|
||||
let sender = IntentMessageConverters.inPerson(from: fromUser)
|
||||
let me = mePerson()
|
||||
|
||||
let intent: INSendMessageIntent
|
||||
if message.toUser != nil {
|
||||
// Direct message
|
||||
intent = INSendMessageIntent(
|
||||
recipients: [me],
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: message.messagePayload,
|
||||
speakableGroupName: nil,
|
||||
conversationIdentifier: "dm-\(fromUser.num)",
|
||||
serviceName: "Meshtastic",
|
||||
sender: sender,
|
||||
attachments: nil
|
||||
)
|
||||
} else {
|
||||
// Channel message
|
||||
let channelName = channelDisplayName(for: message.channel)
|
||||
let groupName = INSpeakableString(spokenPhrase: channelName)
|
||||
intent = INSendMessageIntent(
|
||||
recipients: [me],
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: message.messagePayload,
|
||||
speakableGroupName: groupName,
|
||||
conversationIdentifier: "channel-\(message.channel)",
|
||||
serviceName: "Meshtastic",
|
||||
sender: sender,
|
||||
attachments: nil
|
||||
)
|
||||
intent.setImage(
|
||||
INImage(named: "antenna.radiowaves.left.and.right"),
|
||||
forParameterNamed: \.speakableGroupName
|
||||
)
|
||||
}
|
||||
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
interaction.direction = .incoming
|
||||
interaction.donate { error in
|
||||
if let error {
|
||||
Logger.services.error("🚗 [CarPlay] Failed to donate interaction: \(error.localizedDescription, privacy: .public)")
|
||||
} else {
|
||||
Logger.services.debug("🚗 [CarPlay] Donated incoming message from \(fromUser.longName ?? "unknown", privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Donates an outgoing message interaction after the user sends a message.
|
||||
static func donateOutgoingMessage(content: String, toUserNum: Int64, channel: Int32) {
|
||||
let me = mePerson()
|
||||
|
||||
let intent: INSendMessageIntent
|
||||
if toUserNum != 0 {
|
||||
let handleValue = "\(toUserNum)@meshtastic.local"
|
||||
let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress)
|
||||
let recipient = INPerson(
|
||||
personHandle: recipientHandle,
|
||||
nameComponents: nil,
|
||||
displayName: "Node \(toUserNum.toHex())",
|
||||
image: nil,
|
||||
contactIdentifier: String(toUserNum),
|
||||
customIdentifier: String(toUserNum)
|
||||
)
|
||||
intent = INSendMessageIntent(
|
||||
recipients: [recipient],
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: content,
|
||||
speakableGroupName: nil,
|
||||
conversationIdentifier: "dm-\(toUserNum)",
|
||||
serviceName: "Meshtastic",
|
||||
sender: me,
|
||||
attachments: nil
|
||||
)
|
||||
} else {
|
||||
let channelName = channelDisplayName(for: channel)
|
||||
let groupName = INSpeakableString(spokenPhrase: channelName)
|
||||
intent = INSendMessageIntent(
|
||||
recipients: nil,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: content,
|
||||
speakableGroupName: groupName,
|
||||
conversationIdentifier: "channel-\(channel)",
|
||||
serviceName: "Meshtastic",
|
||||
sender: me,
|
||||
attachments: nil
|
||||
)
|
||||
}
|
||||
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
interaction.direction = .outgoing
|
||||
interaction.donate { error in
|
||||
if let error {
|
||||
Logger.services.error("🚗 [CarPlay] Failed to donate outgoing interaction: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
static func mePerson() -> INPerson {
|
||||
let meHandle = INPersonHandle(value: "me", type: .unknown)
|
||||
return INPerson(
|
||||
personHandle: meHandle,
|
||||
nameComponents: nil,
|
||||
displayName: "Me",
|
||||
image: nil,
|
||||
contactIdentifier: "me",
|
||||
customIdentifier: "me",
|
||||
isMe: true
|
||||
)
|
||||
}
|
||||
|
||||
static func channelDisplayName(for index: Int32) -> String {
|
||||
if index == 0 {
|
||||
return "Primary Channel"
|
||||
}
|
||||
return "Channel \(index)"
|
||||
}
|
||||
}
|
||||
510
Meshtastic/CarPlay/CarPlaySceneDelegate.swift
Normal file
510
Meshtastic/CarPlay/CarPlaySceneDelegate.swift
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
//
|
||||
// CarPlaySceneDelegate.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Copyright(c) Garth Vander Houwen 4/16/26.
|
||||
//
|
||||
// CarPlay Communication app scene delegate.
|
||||
// Uses a tab bar with Channels and Direct Messages tabs,
|
||||
// matching the main app's Messages navigation structure.
|
||||
//
|
||||
|
||||
import CarPlay
|
||||
import Combine
|
||||
import CoreData
|
||||
import Intents
|
||||
import OSLog
|
||||
#if canImport(ActivityKit)
|
||||
import ActivityKit
|
||||
#endif
|
||||
|
||||
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPInterfaceControllerDelegate {
|
||||
|
||||
var interfaceController: CPInterfaceController?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
// Retained template references so we can call updateSections rather than replacing the whole tree.
|
||||
private var channelsTemplate: CPListTemplate?
|
||||
private var directMessagesTemplate: CPListTemplate?
|
||||
// Tracks which conversation identifiers have already had a contact intent donated
|
||||
// during this CarPlay session so we don't re-donate on every refresh.
|
||||
private var donatedConversationIds = Set<String>()
|
||||
|
||||
private lazy var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
|
||||
|
||||
/// Returns a human-readable "last heard" string.
|
||||
/// `now` is passed in so all rows in a single render share one `Date()` allocation.
|
||||
private func lastHeardText(_ date: Date?, now: Date) -> String {
|
||||
guard let date else { return "Never heard" }
|
||||
let interval = now.timeIntervalSince(date)
|
||||
if interval < 60 { return "Just now" }
|
||||
if interval < 3600 { return "\(Int(interval / 60))m ago" }
|
||||
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
|
||||
return "\(Int(interval / 86400))d ago"
|
||||
}
|
||||
|
||||
// MARK: - CPTemplateApplicationSceneDelegate
|
||||
|
||||
func templateApplicationScene(
|
||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||
didConnect interfaceController: CPInterfaceController
|
||||
) {
|
||||
Logger.services.info("🚗 [CarPlay] Connected")
|
||||
self.interfaceController = interfaceController
|
||||
interfaceController.delegate = self
|
||||
|
||||
buildAndSetRootTemplate(animated: false)
|
||||
|
||||
// Observe connection state changes and refresh sections (not the whole template tree).
|
||||
// Debounce absorbs reconnect spikes that would otherwise fire multiple expensive refreshes.
|
||||
AccessoryManager.shared.$isConnected
|
||||
.removeDuplicates()
|
||||
.dropFirst() // Skip initial value — we already built sections above
|
||||
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] isConnected in
|
||||
self?.refreshSections()
|
||||
if isConnected {
|
||||
self?.startLiveActivityIfNeeded()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Start Live Activity immediately if already connected
|
||||
if AccessoryManager.shared.isConnected {
|
||||
startLiveActivityIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func templateApplicationScene(
|
||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
||||
) {
|
||||
Logger.services.info("🚗 [CarPlay] Disconnected")
|
||||
endLiveActivity()
|
||||
cancellables.removeAll()
|
||||
donatedConversationIds.removeAll()
|
||||
channelsTemplate = nil
|
||||
directMessagesTemplate = nil
|
||||
self.interfaceController = nil
|
||||
}
|
||||
|
||||
// MARK: - CPInterfaceControllerDelegate
|
||||
|
||||
func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) {}
|
||||
func templateDidAppear(_ aTemplate: CPTemplate, animated: Bool) {}
|
||||
func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) {}
|
||||
func templateDidDisappear(_ aTemplate: CPTemplate, animated: Bool) {}
|
||||
|
||||
// MARK: - Root Template
|
||||
|
||||
/// Called once at connection time. Builds and caches the two `CPListTemplate` tabs.
|
||||
private func buildAndSetRootTemplate(animated: Bool) {
|
||||
let connected = AccessoryManager.shared.isConnected
|
||||
|
||||
let chTemplate = CPListTemplate(title: "Channels", sections: buildChannelSections(connected: connected))
|
||||
chTemplate.tabImage = UIImage(systemName: "bubble.left.and.bubble.right")
|
||||
channelsTemplate = chTemplate
|
||||
|
||||
let dmTemplate = CPListTemplate(title: "Direct Messages", sections: buildDirectMessageSections(connected: connected))
|
||||
dmTemplate.tabImage = UIImage(systemName: "bubble.left.and.text.bubble.right")
|
||||
directMessagesTemplate = dmTemplate
|
||||
|
||||
let tabBar = CPTabBarTemplate(templates: [chTemplate, dmTemplate])
|
||||
interfaceController?.setRootTemplate(tabBar, animated: animated, completion: nil)
|
||||
}
|
||||
|
||||
/// Called on subsequent connection-state changes — updates sections in-place
|
||||
/// instead of tearing down and rebuilding the entire template hierarchy.
|
||||
private func refreshSections() {
|
||||
let connected = AccessoryManager.shared.isConnected
|
||||
channelsTemplate?.updateSections(buildChannelSections(connected: connected))
|
||||
directMessagesTemplate?.updateSections(buildDirectMessageSections(connected: connected))
|
||||
}
|
||||
|
||||
// MARK: - Section Builders
|
||||
|
||||
private func buildChannelSections(connected: Bool) -> [CPListSection] {
|
||||
guard connected else {
|
||||
let statusItem = CPListItem(
|
||||
text: "Not Connected",
|
||||
detailText: "Open Meshtastic to connect",
|
||||
image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||
)
|
||||
statusItem.isEnabled = false
|
||||
return [CPListSection(items: [statusItem])]
|
||||
}
|
||||
|
||||
let channelItems = fetchChannelItems()
|
||||
if channelItems.isEmpty {
|
||||
let emptyItem = CPListItem(text: "No Channels", detailText: nil)
|
||||
emptyItem.isEnabled = false
|
||||
return [CPListSection(items: [emptyItem])]
|
||||
}
|
||||
return [CPListSection(items: channelItems)]
|
||||
}
|
||||
|
||||
private func buildDirectMessageSections(connected: Bool) -> [CPListSection] {
|
||||
guard connected else {
|
||||
let statusItem = CPListItem(
|
||||
text: "Not Connected",
|
||||
detailText: "Open Meshtastic to connect",
|
||||
image: UIImage(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||
)
|
||||
statusItem.isEnabled = false
|
||||
return [CPListSection(items: [statusItem])]
|
||||
}
|
||||
|
||||
var sections = [CPListSection]()
|
||||
|
||||
let favoriteItems = fetchFavoriteContactItems()
|
||||
if !favoriteItems.isEmpty {
|
||||
sections.append(CPListSection(items: favoriteItems, header: "Favorites", sectionIndexTitle: nil))
|
||||
}
|
||||
|
||||
let dmItems = fetchDirectMessageItems()
|
||||
if !dmItems.isEmpty {
|
||||
sections.append(CPListSection(items: dmItems, header: "Recent", sectionIndexTitle: nil))
|
||||
}
|
||||
|
||||
if favoriteItems.isEmpty && dmItems.isEmpty {
|
||||
let emptyItem = CPListItem(text: "No Messages", detailText: "No direct message history")
|
||||
emptyItem.isEnabled = false
|
||||
sections.append(CPListSection(items: [emptyItem]))
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// MARK: - Data Fetching
|
||||
|
||||
private func fetchFavoriteContactItems() -> [CPMessageListItem] {
|
||||
let request: NSFetchRequest<NodeInfoEntity> = NodeInfoEntity.fetchRequest()
|
||||
request.predicate = NSPredicate(format: "favorite == YES AND num != %lld", AccessoryManager.shared.activeDeviceNum ?? 0)
|
||||
request.sortDescriptors = [NSSortDescriptor(key: "lastHeard", ascending: false)]
|
||||
request.relationshipKeyPathsForPrefetching = ["user"]
|
||||
|
||||
do {
|
||||
let nodes = try context.fetch(request)
|
||||
let nodeNums = nodes.compactMap { $0.user != nil ? $0.num : nil as Int64? }
|
||||
let unreadCounts = fetchUnreadCountsForDMs(nodeNums: nodeNums)
|
||||
let now = Date()
|
||||
|
||||
return nodes.compactMap { node -> CPMessageListItem? in
|
||||
guard let user = node.user else { return nil }
|
||||
let name = user.longName ?? user.shortName ?? "Unknown"
|
||||
let unreadCount = unreadCounts[node.num] ?? 0
|
||||
let hasUnread = unreadCount > 0
|
||||
let convId = "dm-\(node.num)"
|
||||
|
||||
let leadingConfig = CPMessageListItemLeadingConfiguration(
|
||||
leadingItem: .star,
|
||||
leadingImage: UIImage(systemName: "person.circle.fill"),
|
||||
unread: hasUnread
|
||||
)
|
||||
|
||||
let item = CPMessageListItem(
|
||||
fullName: name,
|
||||
phoneOrEmailAddress: "\(node.num)@meshtastic.local",
|
||||
leadingConfiguration: leadingConfig,
|
||||
trailingConfiguration: nil,
|
||||
detailText: hasUnread ? "\(unreadCount) unread" : nil,
|
||||
trailingText: lastHeardText(node.lastHeard, now: now)
|
||||
)
|
||||
item.conversationIdentifier = convId
|
||||
item.userInfo = node.num
|
||||
|
||||
donateMessageIntentIfNeeded(conversationId: convId, toNodeNum: node.num, name: name)
|
||||
|
||||
return item
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🚗 [CarPlay] Failed to fetch favorites: \(error.localizedDescription, privacy: .public)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchChannelItems() -> [CPMessageListItem] {
|
||||
guard let connectedNum = AccessoryManager.shared.activeDeviceNum,
|
||||
let connectedNode = getNodeInfo(id: connectedNum, context: context),
|
||||
let myInfo = connectedNode.myInfo,
|
||||
let channels = myInfo.channels?.array as? [ChannelEntity] else {
|
||||
return []
|
||||
}
|
||||
|
||||
let activeChannels = channels.filter { $0.role > 0 }
|
||||
let channelIndices = activeChannels.map { $0.index }
|
||||
let unreadCounts = fetchUnreadCountsForChannels(channelIndices: channelIndices)
|
||||
|
||||
return activeChannels.compactMap { channel -> CPMessageListItem? in
|
||||
let name = (channel.name?.isEmpty ?? true)
|
||||
? (channel.index == 0 ? "Primary Channel" : "Channel \(channel.index)")
|
||||
: channel.name!
|
||||
let channelIndex = Int(channel.index)
|
||||
let unreadCount = unreadCounts[channel.index] ?? 0
|
||||
let hasUnread = unreadCount > 0
|
||||
let convId = "channel-\(channelIndex)"
|
||||
|
||||
let leadingConfig = CPMessageListItemLeadingConfiguration(
|
||||
leadingItem: .none,
|
||||
leadingImage: UIImage(systemName: channel.index == 0 ? "bubble.left.and.bubble.right.fill" : "bubble.left.and.bubble.right"),
|
||||
unread: hasUnread
|
||||
)
|
||||
|
||||
let item = CPMessageListItem(
|
||||
conversationIdentifier: convId,
|
||||
text: name,
|
||||
leadingConfiguration: leadingConfig,
|
||||
trailingConfiguration: nil,
|
||||
detailText: hasUnread ? "\(unreadCount) unread" : (channel.index == 0 ? "Primary" : "Ch \(channel.index)"),
|
||||
trailingText: nil
|
||||
)
|
||||
item.phoneOrEmailAddress = "\(convId)@meshtastic.local"
|
||||
item.userInfo = channelIndex
|
||||
|
||||
donateChannelIntentIfNeeded(conversationId: convId, channelIndex: channelIndex, channelName: name)
|
||||
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchDirectMessageItems() -> [CPMessageListItem] {
|
||||
let request: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
|
||||
let connectedNum = AccessoryManager.shared.activeDeviceNum ?? 0
|
||||
|
||||
// Match the app's UserList: exclude self, ignored, favorites (shown above).
|
||||
// Use `lastMessage != nil` instead of the expensive `@count` aggregate predicate
|
||||
// to find nodes that have exchanged at least one message.
|
||||
let notSelf = NSPredicate(format: "userNode.num != %lld", connectedNum)
|
||||
let notIgnored = NSPredicate(format: "userNode.ignored == NO")
|
||||
let notFavorite = NSPredicate(format: "userNode.favorite == NO")
|
||||
let hasMessagesOrMessagable = NSCompoundPredicate(type: .or, subpredicates: [
|
||||
NSPredicate(format: "unmessagable == NO"),
|
||||
NSPredicate(format: "lastMessage != nil")
|
||||
])
|
||||
request.predicate = NSCompoundPredicate(type: .and, subpredicates: [notSelf, notIgnored, notFavorite, hasMessagesOrMessagable])
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
|
||||
NSSortDescriptor(key: "lastMessage", ascending: false),
|
||||
NSSortDescriptor(key: "longName", ascending: true)
|
||||
]
|
||||
request.fetchLimit = 24 // CarPlay limits list items
|
||||
request.relationshipKeyPathsForPrefetching = ["userNode"]
|
||||
|
||||
do {
|
||||
let users = try context.fetch(request)
|
||||
let nodeNums = users.compactMap { $0.userNode?.num }
|
||||
let unreadCounts = fetchUnreadCountsForDMs(nodeNums: nodeNums)
|
||||
let now = Date()
|
||||
|
||||
return users.compactMap { user -> CPMessageListItem? in
|
||||
guard let node = user.userNode else { return nil }
|
||||
let name = user.longName ?? user.shortName ?? "Unknown"
|
||||
let nodeNum = node.num
|
||||
let unreadCount = unreadCounts[nodeNum] ?? 0
|
||||
let hasUnread = unreadCount > 0
|
||||
let convId = "dm-\(nodeNum)"
|
||||
|
||||
let leadingConfig = CPMessageListItemLeadingConfiguration(
|
||||
leadingItem: .none,
|
||||
leadingImage: UIImage(systemName: "person.circle.fill"),
|
||||
unread: hasUnread
|
||||
)
|
||||
|
||||
let item = CPMessageListItem(
|
||||
fullName: name,
|
||||
phoneOrEmailAddress: "\(nodeNum)@meshtastic.local",
|
||||
leadingConfiguration: leadingConfig,
|
||||
trailingConfiguration: nil,
|
||||
detailText: hasUnread ? "\(unreadCount) unread" : nil,
|
||||
trailingText: lastHeardText(node.lastHeard, now: now)
|
||||
)
|
||||
item.conversationIdentifier = convId
|
||||
item.userInfo = nodeNum
|
||||
|
||||
donateMessageIntentIfNeeded(conversationId: convId, toNodeNum: nodeNum, name: name)
|
||||
|
||||
return item
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🚗 [CarPlay] Failed to fetch DM users: \(error.localizedDescription, privacy: .public)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Unread Count Batch Fetching
|
||||
|
||||
/// Fetches unread message counts for multiple DM node numbers in a single query,
|
||||
/// then groups the results in-memory. This avoids the N+1 count-per-row pattern
|
||||
/// while staying compatible with Core Data's relationship keypath restrictions.
|
||||
private func fetchUnreadCountsForDMs(nodeNums: [Int64]) -> [Int64: Int] {
|
||||
guard !nodeNums.isEmpty else { return [:] }
|
||||
|
||||
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
NSPredicate(format: "read == NO"),
|
||||
NSPredicate(format: "fromUser.num IN %@", nodeNums)
|
||||
])
|
||||
fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser"]
|
||||
|
||||
let results = (try? context.fetch(fetchRequest)) ?? []
|
||||
var counts = [Int64: Int]()
|
||||
for message in results {
|
||||
if let num = message.fromUser?.num {
|
||||
counts[num, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/// Fetches unread message counts for multiple channel indices in a single query,
|
||||
/// then groups the results in-memory.
|
||||
private func fetchUnreadCountsForChannels(channelIndices: [Int32]) -> [Int32: Int] {
|
||||
guard !channelIndices.isEmpty else { return [:] }
|
||||
|
||||
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
NSPredicate(format: "read == NO"),
|
||||
NSPredicate(format: "toUser == nil"),
|
||||
NSPredicate(format: "channel IN %@", channelIndices)
|
||||
])
|
||||
|
||||
let results = (try? context.fetch(fetchRequest)) ?? []
|
||||
var counts = [Int32: Int]()
|
||||
for message in results {
|
||||
counts[message.channel, default: 0] += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// MARK: - Intent Donation
|
||||
|
||||
/// Donates a contact intent for a DM conversation the first time it is seen this session.
|
||||
/// Subsequent renders are no-ops, avoiding repeated IPC calls to the intents daemon.
|
||||
private func donateMessageIntentIfNeeded(conversationId: String, toNodeNum: Int64, name: String) {
|
||||
guard donatedConversationIds.insert(conversationId).inserted else { return }
|
||||
|
||||
let handleValue = "\(toNodeNum)@meshtastic.local"
|
||||
let person = INPerson(
|
||||
personHandle: INPersonHandle(value: handleValue, type: .emailAddress),
|
||||
nameComponents: nil,
|
||||
displayName: name,
|
||||
image: nil,
|
||||
contactIdentifier: "\(toNodeNum)",
|
||||
customIdentifier: "\(toNodeNum)"
|
||||
)
|
||||
let intent = INSendMessageIntent(
|
||||
recipients: [person],
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: nil,
|
||||
speakableGroupName: nil,
|
||||
conversationIdentifier: conversationId,
|
||||
serviceName: "Meshtastic",
|
||||
sender: nil,
|
||||
attachments: nil
|
||||
)
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
interaction.direction = .outgoing
|
||||
interaction.donate { error in
|
||||
if let error {
|
||||
Logger.services.error("🚗 [CarPlay] DM intent donation error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Donates a contact intent for a channel conversation the first time it is seen this session.
|
||||
private func donateChannelIntentIfNeeded(conversationId: String, channelIndex: Int, channelName: String) {
|
||||
guard donatedConversationIds.insert(conversationId).inserted else { return }
|
||||
|
||||
let channelHandle = "channel-\(channelIndex)@meshtastic.local"
|
||||
let recipient = INPerson(
|
||||
personHandle: INPersonHandle(value: channelHandle, type: .emailAddress),
|
||||
nameComponents: nil,
|
||||
displayName: channelName,
|
||||
image: nil,
|
||||
contactIdentifier: channelHandle,
|
||||
customIdentifier: channelHandle
|
||||
)
|
||||
let groupName = INSpeakableString(spokenPhrase: channelName)
|
||||
let intent = INSendMessageIntent(
|
||||
recipients: [recipient],
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: nil,
|
||||
speakableGroupName: groupName,
|
||||
conversationIdentifier: conversationId,
|
||||
serviceName: "Meshtastic",
|
||||
sender: nil,
|
||||
attachments: nil
|
||||
)
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
interaction.direction = .outgoing
|
||||
interaction.donate { error in
|
||||
if let error {
|
||||
Logger.services.error("🚗 [CarPlay] Channel intent donation error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Activity
|
||||
|
||||
#if canImport(ActivityKit) && !targetEnvironment(macCatalyst)
|
||||
private func startLiveActivityIfNeeded() {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
Logger.services.info("🚗 [CarPlay] Live Activities not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Don't start another if one is already running
|
||||
guard Activity<MeshActivityAttributes>.activities.isEmpty else {
|
||||
Logger.services.info("🚗 [CarPlay] Live Activity already active")
|
||||
return
|
||||
}
|
||||
|
||||
guard let connectedNum = AccessoryManager.shared.activeDeviceNum else { return }
|
||||
let connectedNode = getNodeInfo(id: connectedNum, context: context)
|
||||
let nodeName = connectedNode?.user?.longName ?? "Meshtastic"
|
||||
let nodeShortName = connectedNode?.user?.shortName ?? "?"
|
||||
|
||||
// Fetch latest local stats telemetry
|
||||
let localStats = connectedNode?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
|
||||
let mostRecent = localStats?.lastObject as? TelemetryEntity
|
||||
|
||||
let timerSeconds = 900 // 15 minute local stats interval
|
||||
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
|
||||
let initialState = MeshActivityAttributes.ContentState(
|
||||
uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0),
|
||||
channelUtilization: mostRecent?.channelUtilization ?? 0.0,
|
||||
airtime: mostRecent?.airUtilTx ?? 0.0,
|
||||
sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0),
|
||||
receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0),
|
||||
badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0),
|
||||
dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0),
|
||||
packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0),
|
||||
packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0),
|
||||
nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0),
|
||||
totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0),
|
||||
timerRange: Date.now...future
|
||||
)
|
||||
|
||||
let attributes = MeshActivityAttributes(nodeNum: Int(connectedNum), name: nodeName, shortName: nodeShortName)
|
||||
let content = ActivityContent(state: initialState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!)
|
||||
|
||||
do {
|
||||
let activity = try Activity<MeshActivityAttributes>.request(attributes: attributes, content: content, pushType: nil)
|
||||
Logger.services.info("🚗 [CarPlay] Started Live Activity: \(activity.id)")
|
||||
} catch {
|
||||
Logger.services.error("🚗 [CarPlay] Failed to start Live Activity: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func endLiveActivity() {
|
||||
Task {
|
||||
for activity in Activity<MeshActivityAttributes>.activities {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
Logger.services.info("🚗 [CarPlay] Ended Live Activity: \(activity.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
private func startLiveActivityIfNeeded() {}
|
||||
private func endLiveActivity() {}
|
||||
#endif
|
||||
}
|
||||
|
|
@ -218,9 +218,10 @@ extension UserDefaults {
|
|||
}
|
||||
@UserDefault(.lastDeviceAPIUpdate, defaultValue: .distantPast)
|
||||
static var lastDeviceAPIUpdate: Date
|
||||
|
||||
|
||||
@UserDefault(.lastFirmwareAPIUpdate, defaultValue: .distantPast)
|
||||
static var lastFirmwareAPIUpdate: Date
|
||||
}
|
||||
|
||||
enum TestIntEnum: Int, Decodable {
|
||||
case one = 1
|
||||
|
|
|
|||
|
|
@ -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, *) {
|
||||
|
|
|
|||
|
|
@ -174,10 +174,20 @@ struct GeoJSONStyledFeature: Identifiable {
|
|||
let id = UUID()
|
||||
let feature: GeoJSONFeature
|
||||
let overlayId: String
|
||||
/// MKOverlay pre-computed once at init — avoids repeated JSONSerialization + MKGeoJSONDecoder
|
||||
/// calls on every map render pass.
|
||||
let precomputedOverlay: MKOverlay?
|
||||
|
||||
/// Create MKOverlay from this styled feature
|
||||
func createOverlay() -> MKOverlay? {
|
||||
// Convert feature to standard GeoJSON format for MKGeoJSONDecoder
|
||||
init(feature: GeoJSONFeature, overlayId: String) {
|
||||
self.feature = feature
|
||||
self.overlayId = overlayId
|
||||
// Call the static helper after all stored properties are assigned so `self` is available
|
||||
// for the instance — but we don't actually need self here, so this is safe.
|
||||
self.precomputedOverlay = GeoJSONStyledFeature.makeOverlay(for: feature)
|
||||
}
|
||||
|
||||
/// Builds an MKOverlay from a GeoJSON feature. Static so it can be called from init.
|
||||
private static func makeOverlay(for feature: GeoJSONFeature) -> MKOverlay? {
|
||||
let featureDict: [String: Any] = [
|
||||
"type": feature.type,
|
||||
"geometry": [
|
||||
|
|
@ -188,31 +198,23 @@ struct GeoJSONStyledFeature: Identifiable {
|
|||
]
|
||||
|
||||
do {
|
||||
// Serialize feature dictionary to JSON data
|
||||
let geojsonData = try JSONSerialization.data(withJSONObject: featureDict)
|
||||
do {
|
||||
// Decode GeoJSON data into MKGeoJSONFeature objects
|
||||
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
|
||||
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature {
|
||||
// Extract geometry and create overlay
|
||||
if let geometry = mkFeature.geometry.first as? MKOverlay {
|
||||
// Successfully created overlay
|
||||
return geometry
|
||||
} else {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - Geometry is not an MKOverlay.")
|
||||
}
|
||||
} else {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON - No valid MKGeoJSONFeature found.")
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to decode GeoJSON data: \(error.localizedDescription)")
|
||||
let mkFeatures = try MKGeoJSONDecoder().decode(geojsonData)
|
||||
if let mkFeature = mkFeatures.first as? MKGeoJSONFeature,
|
||||
let geometry = mkFeature.geometry.first as? MKOverlay {
|
||||
return geometry
|
||||
} else {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to create overlay - no valid MKOverlay geometry.")
|
||||
}
|
||||
} catch {
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to serialize feature dictionary to JSON: \(error.localizedDescription)")
|
||||
Logger.services.error("🗺️ GeoJSONStyledFeature: Failed to build overlay: \(error.localizedDescription)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns the pre-computed overlay. Retained for API compatibility.
|
||||
func createOverlay() -> MKOverlay? { precomputedOverlay }
|
||||
|
||||
/// Get stroke style for this feature
|
||||
var strokeStyle: StrokeStyle {
|
||||
let dashArray = feature.lineDashArray
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ class GeoJSONOverlayManager {
|
|||
private init() {}
|
||||
|
||||
private var featureCollection: GeoJSONFeatureCollection?
|
||||
// Cache the last styled-features result keyed by the enabled-configs set.
|
||||
// GeoJSONStyledFeature instances have stable UUIDs once created, so SwiftUI's
|
||||
// ForEach diffing correctly skips unchanged overlays between renders.
|
||||
private var styledFeaturesCache: (configs: Set<UUID>, features: [GeoJSONStyledFeature])?
|
||||
|
||||
/// Load raw GeoJSON feature collection from user uploads
|
||||
func loadFeatureCollection() -> GeoJSONFeatureCollection? {
|
||||
|
|
@ -24,36 +28,35 @@ class GeoJSONOverlayManager {
|
|||
return nil
|
||||
}
|
||||
|
||||
/// Load styled features for specific enabled configs
|
||||
/// Load styled features for specific enabled configs.
|
||||
/// Results are cached per unique `enabledConfigs` set — file I/O and JSON decoding
|
||||
/// only happen when the set changes, not on every map render.
|
||||
func loadStyledFeaturesForConfigs(_ enabledConfigs: Set<UUID>) -> [GeoJSONStyledFeature] {
|
||||
// Get files that match the enabled configs
|
||||
let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) }
|
||||
if let cache = styledFeaturesCache, cache.configs == enabledConfigs {
|
||||
return cache.features
|
||||
}
|
||||
|
||||
let enabledFiles = MapDataManager.shared.getUploadedFiles().filter { enabledConfigs.contains($0.id) }
|
||||
guard !enabledFiles.isEmpty else {
|
||||
styledFeaturesCache = (configs: enabledConfigs, features: [])
|
||||
return []
|
||||
}
|
||||
|
||||
// Load feature collection from enabled files only
|
||||
guard let collection = MapDataManager.shared.loadFeatureCollectionForFiles(enabledFiles) else {
|
||||
styledFeaturesCache = (configs: enabledConfigs, features: [])
|
||||
return []
|
||||
}
|
||||
|
||||
var styledFeatures: [GeoJSONStyledFeature] = []
|
||||
|
||||
for feature in collection.features {
|
||||
// Skip invisible features
|
||||
guard feature.isVisible else {
|
||||
continue
|
||||
}
|
||||
|
||||
let layerId = feature.layerId ?? "default"
|
||||
let styledFeature = GeoJSONStyledFeature(
|
||||
guard feature.isVisible else { continue }
|
||||
styledFeatures.append(GeoJSONStyledFeature(
|
||||
feature: feature,
|
||||
overlayId: layerId
|
||||
)
|
||||
styledFeatures.append(styledFeature)
|
||||
overlayId: feature.layerId ?? "default"
|
||||
))
|
||||
}
|
||||
|
||||
styledFeaturesCache = (configs: enabledConfigs, features: styledFeatures)
|
||||
return styledFeatures
|
||||
}
|
||||
|
||||
|
|
@ -106,9 +109,10 @@ class GeoJSONOverlayManager {
|
|||
return Array(layerIds).sorted()
|
||||
}
|
||||
|
||||
/// Clear cached data (useful for testing or memory management)
|
||||
/// Clear cached data (called when files are added, deleted, or toggled).
|
||||
func clearCache() {
|
||||
featureCollection = nil
|
||||
styledFeaturesCache = nil
|
||||
}
|
||||
|
||||
/// Check if user-uploaded data is available (regardless of active state)
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ actor MeshPackets {
|
|||
myInfoEntity.rebootCount = Int32(myInfo.rebootCount)
|
||||
myInfoEntity.deviceId = myInfo.deviceID
|
||||
myInfoEntity.pioEnv = myInfo.pioEnv
|
||||
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
Logger.data.info("💾 Saved a new myInfo for node: \(myInfo.myNodeNum.toHex(), privacy: .public)")
|
||||
|
|
@ -1088,6 +1088,9 @@ actor MeshPackets {
|
|||
}
|
||||
// Send notifications if the message saved properly to core data
|
||||
if messageSaved {
|
||||
// Donate to SiriKit so the message appears in CarPlay Messages
|
||||
CarPlayIntentDonation.donateReceivedMessage(newMessage)
|
||||
|
||||
if packet.decoded.portnum == PortNum.detectionSensorApp && !UserDefaults.enableDetectionNotifications {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,12 @@
|
|||
<string>INSearchForMessagesIntent</string>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
</array>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
|
|
@ -113,6 +119,8 @@
|
|||
<string>We use the camera to share channels using a QR Code</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>We use local networking to connect to network-based nodes.</string>
|
||||
<key>NSSiriUsageDescription</key>
|
||||
<string>Siri and Shortcuts let you control Meshtastic hands-free — send messages, disconnect, restart, or shut down your node with your voice.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>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.</string>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
|
|
@ -131,6 +139,27 @@
|
|||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>CPTemplateApplicationScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>CarPlay</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
|
|
@ -324,7 +353,5 @@
|
|||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>com.apple.developer.carplay-communication</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import CoreData
|
|||
import Intents
|
||||
|
||||
enum IntentMessageConverters {
|
||||
static let meshtasticDomain = "@meshtastic.local"
|
||||
|
||||
/// Converts a `UserEntity` to an `INPerson` for use with SiriKit intents.
|
||||
/// Uses the `@meshtastic.local` email format so the handle matches `CPContactMessageButton` identifiers.
|
||||
static func inPerson(from user: UserEntity) -> INPerson {
|
||||
let handle = INPersonHandle(value: String(user.num), type: .unknown)
|
||||
let handleValue = "\(user.num)\(meshtasticDomain)"
|
||||
let handle = INPersonHandle(value: handleValue, type: .emailAddress)
|
||||
return INPerson(
|
||||
personHandle: handle,
|
||||
nameComponents: nil,
|
||||
|
|
@ -29,8 +32,8 @@ enum IntentMessageConverters {
|
|||
let sender: INPerson? = message.fromUser.map { inPerson(from: $0) }
|
||||
let recipients: [INPerson]? = message.toUser.map { [inPerson(from: $0)] }
|
||||
let dateSent = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
|
||||
let groupName: INSpeakableString? = message.channel > 0
|
||||
? INSpeakableString(spokenPhrase: "Channel \(message.channel)")
|
||||
let groupName: INSpeakableString? = message.toUser == nil
|
||||
? INSpeakableString(spokenPhrase: channelDisplayName(for: message.channel, named: nil))
|
||||
: nil
|
||||
|
||||
return INMessage(
|
||||
|
|
@ -56,16 +59,30 @@ enum IntentMessageConverters {
|
|||
|
||||
/// Searches for `UserEntity` objects whose name matches the given search term.
|
||||
static func findUsers(matching searchTerm: String, in context: NSManagedObjectContext) -> [UserEntity] {
|
||||
if let nodeNum = directMessageNodeNum(from: searchTerm) {
|
||||
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1
|
||||
fetchRequest.predicate = NSPredicate(format: "num == %lld", nodeNum)
|
||||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
let fetchRequest: NSFetchRequest<UserEntity> = UserEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@",
|
||||
searchTerm, searchTerm
|
||||
format: "longName CONTAINS[cd] %@ OR shortName CONTAINS[cd] %@ OR userId CONTAINS[cd] %@",
|
||||
searchTerm, searchTerm, searchTerm
|
||||
)
|
||||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
/// Looks up a `ChannelEntity` by matching name.
|
||||
static func findChannels(matching name: String, in context: NSManagedObjectContext) -> [ChannelEntity] {
|
||||
if let explicitIndex = channelIndex(fromHandleOrName: name) {
|
||||
let fetchRequest: NSFetchRequest<ChannelEntity> = ChannelEntity.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1
|
||||
fetchRequest.predicate = NSPredicate(format: "index == %d", explicitIndex)
|
||||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
let fetchRequest: NSFetchRequest<ChannelEntity> = ChannelEntity.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "name != nil AND name != '' AND name CONTAINS[cd] %@", name
|
||||
|
|
@ -75,7 +92,57 @@ enum IntentMessageConverters {
|
|||
|
||||
/// Resolves a channel index from a spoken group name, defaulting to the primary channel.
|
||||
static func channelIndex(for name: String, in context: NSManagedObjectContext) -> Int {
|
||||
if let explicitIndex = channelIndex(fromHandleOrName: name) {
|
||||
return explicitIndex
|
||||
}
|
||||
|
||||
let channels = findChannels(matching: name, in: context)
|
||||
return channels.first.map { Int($0.index) } ?? 0
|
||||
}
|
||||
|
||||
static func directMessageNodeNum(from value: String) -> Int64? {
|
||||
if let nodeNum = Int64(value) {
|
||||
return nodeNum
|
||||
}
|
||||
|
||||
if value.hasSuffix(meshtasticDomain) {
|
||||
let rawValue = String(value.dropLast(meshtasticDomain.count))
|
||||
return Int64(rawValue)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func channelIndex(fromHandleOrName value: String) -> Int? {
|
||||
if value.caseInsensitiveCompare("Primary Channel") == .orderedSame {
|
||||
return 0
|
||||
}
|
||||
|
||||
if value.hasPrefix("Channel "), let index = Int(value.dropFirst("Channel ".count)) {
|
||||
return index
|
||||
}
|
||||
|
||||
let channelPrefix = "channel-"
|
||||
if value.hasPrefix(channelPrefix) {
|
||||
let remainder = String(value.dropFirst(channelPrefix.count))
|
||||
let rawIndex = remainder.hasSuffix(meshtasticDomain)
|
||||
? String(remainder.dropLast(meshtasticDomain.count))
|
||||
: remainder
|
||||
return Int(rawIndex)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func channelDisplayName(for index: Int32, named name: String?) -> String {
|
||||
if let name, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
|
||||
if index == 0 {
|
||||
return "Primary Channel"
|
||||
}
|
||||
|
||||
return "Channel \(index)"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
|
|||
// MARK: - Handling
|
||||
|
||||
func handle(intent: INSearchForMessagesIntent) async -> INSearchForMessagesIntentResponse {
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
// Use a private background context so the fetch does not block the main thread.
|
||||
let bgContext = PersistenceController.shared.container.newBackgroundContext()
|
||||
bgContext.automaticallyMergesChangesFromParent = true
|
||||
|
||||
let messages: [INMessage] = await MainActor.run {
|
||||
let messages: [INMessage] = await bgContext.perform {
|
||||
let fetchRequest: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
|
||||
var predicates: [NSPredicate] = []
|
||||
|
||||
|
|
@ -29,6 +31,22 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
|
|||
predicates.append(NSPredicate(format: "admin == NO"))
|
||||
predicates.append(NSPredicate(format: "isEmoji == NO"))
|
||||
|
||||
// Filter by conversation identifiers (e.g., "dm-123456" or "channel-0")
|
||||
// This is the primary filter when Siri reads messages for a CarPlay contact.
|
||||
if let conversationIds = intent.conversationIdentifiers, !conversationIds.isEmpty {
|
||||
var conversationPredicates: [NSPredicate] = []
|
||||
for convId in conversationIds {
|
||||
if convId.hasPrefix("dm-"), let nodeNum = Int64(convId.dropFirst("dm-".count)) {
|
||||
conversationPredicates.append(NSPredicate(format: "fromUser.num == %lld", nodeNum))
|
||||
} else if convId.hasPrefix("channel-"), let channelIndex = Int32(convId.dropFirst("channel-".count)) {
|
||||
conversationPredicates.append(NSPredicate(format: "channel == %d AND toUser == nil", channelIndex))
|
||||
}
|
||||
}
|
||||
if !conversationPredicates.isEmpty {
|
||||
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: conversationPredicates))
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by identifiers (specific message IDs)
|
||||
if let identifiers = intent.identifiers, !identifiers.isEmpty {
|
||||
let messageIds = identifiers.compactMap { Int64($0) }
|
||||
|
|
@ -37,9 +55,12 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
|
|||
}
|
||||
}
|
||||
|
||||
// Filter by sender
|
||||
// Filter by sender — parse @meshtastic.local email-format handles
|
||||
if let senders = intent.senders, !senders.isEmpty {
|
||||
let senderNums = senders.compactMap { $0.personHandle?.value }.compactMap { Int64($0) }
|
||||
let senderNums = senders.compactMap { sender -> Int64? in
|
||||
guard let handleValue = sender.personHandle?.value else { return nil }
|
||||
return IntentMessageConverters.directMessageNodeNum(from: handleValue)
|
||||
}
|
||||
if !senderNums.isEmpty {
|
||||
predicates.append(NSPredicate(format: "fromUser.num IN %@", senderNums))
|
||||
}
|
||||
|
|
@ -62,16 +83,19 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
|
|||
}
|
||||
}
|
||||
|
||||
// Filter by group/channel name
|
||||
// Filter by group/channel name or handle
|
||||
if let groupNames = intent.speakableGroupNames, !groupNames.isEmpty {
|
||||
let channelIndices: [Int32] = groupNames.compactMap { groupName in
|
||||
if let idx = IntentMessageConverters.channelIndex(fromHandleOrName: groupName.spokenPhrase) {
|
||||
return Int32(idx)
|
||||
}
|
||||
let channels = IntentMessageConverters.findChannels(
|
||||
matching: groupName.spokenPhrase, in: context
|
||||
matching: groupName.spokenPhrase, in: bgContext
|
||||
)
|
||||
return channels.first.map { Int32($0.index) }
|
||||
}
|
||||
if !channelIndices.isEmpty {
|
||||
predicates.append(NSPredicate(format: "channel IN %@", channelIndices))
|
||||
predicates.append(NSPredicate(format: "channel IN %@ AND toUser == nil", channelIndices))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +115,7 @@ final class SearchForMessagesIntentHandler: NSObject, INSearchForMessagesIntentH
|
|||
fetchRequest.relationshipKeyPathsForPrefetching = ["fromUser", "toUser"]
|
||||
|
||||
do {
|
||||
let results = try context.fetch(fetchRequest)
|
||||
let results = try bgContext.fetch(fetchRequest)
|
||||
return results.map { IntentMessageConverters.inMessage(from: $0) }
|
||||
} catch {
|
||||
Logger.services.error("CarPlay/Siri: Failed to search messages: \(error.localizedDescription)")
|
||||
|
|
|
|||
|
|
@ -30,7 +30,21 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
|
|||
}
|
||||
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let searchTerm = recipients[0].displayName
|
||||
let recipient = recipients[0]
|
||||
let handleValue = recipient.personHandle?.value ?? ""
|
||||
|
||||
// If this is a channel handle, accept it directly
|
||||
if IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) != nil {
|
||||
return [.success(with: recipient)]
|
||||
}
|
||||
|
||||
// If the handle resolves to a node number, accept it directly
|
||||
if IntentMessageConverters.directMessageNodeNum(from: handleValue) != nil {
|
||||
return [.success(with: recipient)]
|
||||
}
|
||||
|
||||
// Otherwise search by display name
|
||||
let searchTerm = recipient.displayName ?? handleValue
|
||||
let matchingUsers = await MainActor.run {
|
||||
IntentMessageConverters.findUsers(matching: searchTerm, in: context)
|
||||
}
|
||||
|
|
@ -71,11 +85,15 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
|
|||
}
|
||||
|
||||
if matchingChannels.count == 1, let channel = matchingChannels.first {
|
||||
let speakable = INSpeakableString(spokenPhrase: channel.name ?? "Channel \(channel.index)")
|
||||
let speakable = INSpeakableString(
|
||||
spokenPhrase: IntentMessageConverters.channelDisplayName(for: channel.index, named: channel.name)
|
||||
)
|
||||
return .success(with: speakable)
|
||||
} else if matchingChannels.count > 1 {
|
||||
let speakables = matchingChannels.map {
|
||||
INSpeakableString(spokenPhrase: $0.name ?? "Channel \($0.index)")
|
||||
INSpeakableString(
|
||||
spokenPhrase: IntentMessageConverters.channelDisplayName(for: $0.index, named: $0.name)
|
||||
)
|
||||
}
|
||||
return .disambiguation(with: speakables)
|
||||
}
|
||||
|
|
@ -120,16 +138,27 @@ final class SendMessageIntentHandler: NSObject, INSendMessageIntentHandling {
|
|||
replyID: 0
|
||||
)
|
||||
} else if let recipient = intent.recipients?.first,
|
||||
let handleValue = recipient.personHandle?.value,
|
||||
let nodeNum = Int64(handleValue) {
|
||||
let handleValue = recipient.personHandle?.value {
|
||||
if let channelIndex = IntentMessageConverters.channelIndex(fromHandleOrName: handleValue) {
|
||||
try await AccessoryManager.shared.sendMessage(
|
||||
message: content,
|
||||
toUserNum: 0,
|
||||
channel: Int32(channelIndex),
|
||||
isEmoji: false,
|
||||
replyID: 0
|
||||
)
|
||||
} else if let nodeNum = IntentMessageConverters.directMessageNodeNum(from: handleValue) {
|
||||
// Direct message to a single node
|
||||
try await AccessoryManager.shared.sendMessage(
|
||||
message: content,
|
||||
toUserNum: nodeNum,
|
||||
channel: 0,
|
||||
isEmoji: false,
|
||||
replyID: 0
|
||||
)
|
||||
try await AccessoryManager.shared.sendMessage(
|
||||
message: content,
|
||||
toUserNum: nodeNum,
|
||||
channel: 0,
|
||||
isEmoji: false,
|
||||
replyID: 0
|
||||
)
|
||||
} else {
|
||||
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
|
||||
}
|
||||
} else {
|
||||
return INSendMessageIntentResponse(code: .failure, userActivity: nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,9 +39,11 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt
|
|||
}
|
||||
|
||||
let attribute = intent.attribute
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
// Use a private background context so Core Data work does not block the main thread.
|
||||
let bgContext = PersistenceController.shared.container.newBackgroundContext()
|
||||
bgContext.automaticallyMergesChangesFromParent = true
|
||||
|
||||
let success: Bool = await MainActor.run {
|
||||
let success: Bool = await bgContext.perform {
|
||||
let messageIds = identifiers.compactMap { Int64($0) }
|
||||
guard !messageIds.isEmpty else { return false }
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt
|
|||
fetchRequest.predicate = NSPredicate(format: "messageId IN %@", messageIds)
|
||||
|
||||
do {
|
||||
let messages = try context.fetch(fetchRequest)
|
||||
let messages = try bgContext.fetch(fetchRequest)
|
||||
guard !messages.isEmpty else { return false }
|
||||
|
||||
for message in messages {
|
||||
|
|
@ -66,8 +68,8 @@ final class SetMessageAttributeIntentHandler: NSObject, INSetMessageAttributeInt
|
|||
}
|
||||
}
|
||||
|
||||
if context.hasChanges {
|
||||
try context.save()
|
||||
if bgContext.hasChanges {
|
||||
try bgContext.save()
|
||||
}
|
||||
Logger.services.info("CarPlay/Siri: Updated \(messages.count) message(s) to \(String(describing: attribute))")
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -2,44 +2,44 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.siri</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:meshtastic.org/e/*</string>
|
||||
<string>applinks:meshtastic.org/v/*</string>
|
||||
</array>
|
||||
<key>com.apple.developer.carplay-communication</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.networking.custom-protocol</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||
<array>
|
||||
<string>TAG</string>
|
||||
</array>
|
||||
<key>com.apple.developer.usernotifications.critical-alerts</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.weatherkit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.serial</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)gvh.MeshtasticClient</string>
|
||||
</array>
|
||||
<key>com.apple.developer.siri</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:meshtastic.org/e/*</string>
|
||||
<string>applinks:meshtastic.org/v/*</string>
|
||||
</array>
|
||||
<key>com.apple.developer.carplay-communication</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.networking.custom-protocol</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.nfc.readersession.formats</key>
|
||||
<array>
|
||||
<string>TAG</string>
|
||||
</array>
|
||||
<key>com.apple.developer.usernotifications.critical-alerts</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.weatherkit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.serial</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)gvh.MeshtasticClient</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
|
|||
} else {
|
||||
Logger.services.info("📋 Device list API data update is not needed...")
|
||||
}
|
||||
|
||||
|
||||
// Initialize TAK Server if enabled
|
||||
Task { @MainActor in
|
||||
TAKServerManager.shared.initializeOnStartup()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -376,7 +394,7 @@ struct Connect: View {
|
|||
let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
|
||||
let mostRecent = localStats?.lastObject as? TelemetryEntity
|
||||
|
||||
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown")
|
||||
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown", shortName: node?.user?.shortName ?? "?")
|
||||
|
||||
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
|
||||
let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
103
Meshtastic/Views/Connect/SecurityVersionNag.swift
Normal file
103
Meshtastic/Views/Connect/SecurityVersionNag.swift
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,6 @@ struct CompassView: View {
|
|||
let waypointLocation: CLLocationCoordinate2D?
|
||||
let waypointLongName: String?
|
||||
let waypointShortName: String?
|
||||
var waypointName: String? { waypointLongName }
|
||||
let color: Color
|
||||
|
||||
@ObservedObject private var locationsHandler = LocationsHandler.shared
|
||||
|
|
@ -83,7 +82,7 @@ struct CompassView: View {
|
|||
if waypointLongName != nil || waypointLocation != nil {
|
||||
Spacer()
|
||||
VStack(spacing: 4) {
|
||||
Text(waypointLongName ?? waypointName ?? "Waypoint")
|
||||
Text(waypointLongName ?? "Waypoint")
|
||||
.font(.largeTitle)
|
||||
|
||||
if let bearing = bearingToWaypoint() {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ struct MeshMapContent: MapContent {
|
|||
@AppStorage("mapOverlaysEnabled") private var showMapOverlays = false
|
||||
@Binding var enabledOverlayConfigs: Set<UUID>
|
||||
|
||||
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .easeIn)
|
||||
@FetchRequest(fetchRequest: PositionEntity.allPositionsFetchRequest(), animation: .none)
|
||||
var positions: FetchedResults<PositionEntity>
|
||||
|
||||
@FetchRequest(fetchRequest: WaypointEntity.allWaypointssFetchRequest(), animation: .none)
|
||||
|
|
@ -184,10 +184,13 @@ struct MeshMapContent: MapContent {
|
|||
|
||||
@MapContentBuilder
|
||||
var meshMap: some MapContent {
|
||||
let loraNodes = positions.filter { $0.nodePosition?.viaMqtt ?? true == false }
|
||||
let loraCoords = Array(loraNodes).compactMap({(position) -> CLLocationCoordinate2D in
|
||||
return position.nodeCoordinate ?? LocationsHandler.DefaultLocation
|
||||
})
|
||||
// Only compute LoRa node coordinates when the convex hull is actually displayed.
|
||||
// The filter scans the entire positions array on every render, so guard it.
|
||||
let loraCoords: [CLLocationCoordinate2D] = showConvexHull
|
||||
? positions
|
||||
.filter { !($0.nodePosition?.viaMqtt ?? true) }
|
||||
.compactMap { $0.nodeCoordinate ?? LocationsHandler.DefaultLocation }
|
||||
: []
|
||||
/// Convex Hull
|
||||
if showConvexHull {
|
||||
if loraCoords.count > 0 {
|
||||
|
|
@ -214,8 +217,10 @@ struct MeshMapContent: MapContent {
|
|||
let allStyledFeatures = GeoJSONOverlayManager.shared.loadStyledFeaturesForConfigs(enabledOverlayConfigs)
|
||||
|
||||
return Group {
|
||||
ForEach(0..<allStyledFeatures.count, id: \.self) { index in
|
||||
let styledFeature = allStyledFeatures[index]
|
||||
// GeoJSONStyledFeature is Identifiable with a stable UUID assigned at creation.
|
||||
// Using ForEach with Identifiable gives SwiftUI stable identity for diffing,
|
||||
// avoiding full teardown/rebuild of overlay views on each render.
|
||||
ForEach(allStyledFeatures) { styledFeature in
|
||||
let feature = styledFeature.feature
|
||||
let geometryType = feature.geometry.type
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ struct NodeMapContent: MapContent {
|
|||
@Namespace var mapScope
|
||||
@State var selectedPosition: PositionEntity?
|
||||
|
||||
// Static UIImage caches keyed by node.num.
|
||||
// Node colors are deterministic from node.num (via UIColor(hex:)), so caching by num is correct.
|
||||
// nonisolated(unsafe) is required for static mutable state in Swift 6.
|
||||
private nonisolated(unsafe) static var circleImageCache: [Int64: UIImage] = [:]
|
||||
private nonisolated(unsafe) static var arrowImageCache: [Int64: UIImage] = [:]
|
||||
|
||||
@MapContentBuilder
|
||||
var nodeMap: some MapContent {
|
||||
let positionArray = node.positions?.array as? [PositionEntity] ?? []
|
||||
|
|
@ -160,18 +166,20 @@ struct NodeMapContent: MapContent {
|
|||
}
|
||||
|
||||
private func prerenderHistoryPointCircle(fill: Color, stroke: Color) -> UIImage {
|
||||
// Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points.
|
||||
if let cached = NodeMapContent.circleImageCache[node.num] { return cached }
|
||||
let content = Circle()
|
||||
.fill(fill)
|
||||
.strokeBorder(stroke, lineWidth: 2)
|
||||
.frame(width: 12, height: 12)
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = UIScreen.main.scale
|
||||
return renderer.uiImage!
|
||||
let image = renderer.uiImage!
|
||||
NodeMapContent.circleImageCache[node.num] = image
|
||||
return image
|
||||
}
|
||||
|
||||
private func prerenderHistoryPointArrow(fill: Color, stroke: Color) -> UIImage {
|
||||
// Render to UIImage once so we don't have to do a ton of vector operations and layers when there are thousands of history points.
|
||||
if let cached = NodeMapContent.arrowImageCache[node.num] { return cached }
|
||||
let content = Image(systemName: "location.north.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
|
|
@ -181,6 +189,8 @@ struct NodeMapContent: MapContent {
|
|||
.frame(width: 16, height: 16)
|
||||
let renderer = ImageRenderer(content: content)
|
||||
renderer.scale = UIScreen.main.scale
|
||||
return renderer.uiImage!
|
||||
let image = renderer.uiImage!
|
||||
NodeMapContent.arrowImageCache[node.num] = image
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
36
Meshtastic/da.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/da.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Send en besked på Meshtastic</string>
|
||||
<string>Send en Meshtastic-besked til Lars</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Søg efter Meshtastic-beskeder</string>
|
||||
<string>Find beskeder på Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Markér Meshtastic-besked som læst</string>
|
||||
<string>Markér Meshtastic-beskeder som læst</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/de.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/de.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Sende eine Nachricht über Meshtastic</string>
|
||||
<string>Sende eine Meshtastic-Nachricht an Hans</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Suche Meshtastic-Nachrichten</string>
|
||||
<string>Finde Nachrichten auf Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Markiere Meshtastic-Nachricht als gelesen</string>
|
||||
<string>Markiere Meshtastic-Nachrichten als gelesen</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/en.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/en.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Send a message on Meshtastic</string>
|
||||
<string>Send a Meshtastic message to John</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Search Meshtastic messages</string>
|
||||
<string>Find messages on Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Mark Meshtastic message as read</string>
|
||||
<string>Mark Meshtastic messages as read</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/es.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/es.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Enviar un mensaje en Meshtastic</string>
|
||||
<string>Enviar un mensaje de Meshtastic a Juan</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Buscar mensajes de Meshtastic</string>
|
||||
<string>Encontrar mensajes en Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Marcar mensaje de Meshtastic como leído</string>
|
||||
<string>Marcar mensajes de Meshtastic como leídos</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/fr.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/fr.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Envoyer un message sur Meshtastic</string>
|
||||
<string>Envoyer un message Meshtastic à Pierre</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Chercher des messages Meshtastic</string>
|
||||
<string>Trouver des messages sur Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Marquer le message Meshtastic comme lu</string>
|
||||
<string>Marquer les messages Meshtastic comme lus</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/he.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/he.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>שלח הודעה ב-Meshtastic</string>
|
||||
<string>שלח הודעת Meshtastic לדוד</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>חפש הודעות Meshtastic</string>
|
||||
<string>מצא הודעות ב-Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>סמן הודעת Meshtastic כנקראה</string>
|
||||
<string>סמן הודעות Meshtastic כנקראו</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/it.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/it.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Invia un messaggio su Meshtastic</string>
|
||||
<string>Invia un messaggio Meshtastic a Marco</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Cerca messaggi su Meshtastic</string>
|
||||
<string>Trova messaggi su Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Segna il messaggio Meshtastic come letto</string>
|
||||
<string>Segna i messaggi Meshtastic come letti</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/ja.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/ja.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Meshtasticでメッセージを送信</string>
|
||||
<string>Meshtasticで太郎にメッセージを送って</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Meshtasticのメッセージを検索</string>
|
||||
<string>Meshtasticでメッセージを探して</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Meshtasticのメッセージを既読にして</string>
|
||||
<string>Meshtasticのメッセージを既読にする</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/pl.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/pl.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Wyślij wiadomość przez Meshtastic</string>
|
||||
<string>Wyślij wiadomość Meshtastic do Jana</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Szukaj wiadomości Meshtastic</string>
|
||||
<string>Znajdź wiadomości w Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Oznacz wiadomość Meshtastic jako przeczytaną</string>
|
||||
<string>Oznacz wiadomości Meshtastic jako przeczytane</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/ru.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/ru.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Отправить сообщение через Meshtastic</string>
|
||||
<string>Отправить сообщение Meshtastic Ивану</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Найти сообщения в Meshtastic</string>
|
||||
<string>Поиск сообщений Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Отметить сообщение Meshtastic как прочитанное</string>
|
||||
<string>Отметить сообщения Meshtastic как прочитанные</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/se.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/se.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Skicka ett meddelande på Meshtastic</string>
|
||||
<string>Skicka ett Meshtastic-meddelande till Erik</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Sök Meshtastic-meddelanden</string>
|
||||
<string>Hitta meddelanden på Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Markera Meshtastic-meddelande som läst</string>
|
||||
<string>Markera Meshtastic-meddelanden som lästa</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/sr.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/sr.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Пошаљи поруку преко Meshtastic</string>
|
||||
<string>Пошаљи Meshtastic поруку Марку</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Претражи Meshtastic поруке</string>
|
||||
<string>Пронађи поруке на Meshtastic</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>Означи Meshtastic поруку као прочитану</string>
|
||||
<string>Означи Meshtastic поруке као прочитане</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/zh-Hans.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/zh-Hans.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>通过Meshtastic发送消息</string>
|
||||
<string>用Meshtastic给小明发消息</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>搜索Meshtastic消息</string>
|
||||
<string>查找Meshtastic消息</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>将Meshtastic消息标记为已读</string>
|
||||
<string>标记Meshtastic消息为已读</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
36
Meshtastic/zh-Hant-TW.lproj/AppIntentVocabulary.plist
Normal file
36
Meshtastic/zh-Hant-TW.lproj/AppIntentVocabulary.plist
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IntentPhrases</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSendMessageIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>透過Meshtastic傳送訊息</string>
|
||||
<string>用Meshtastic傳訊息給小明</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSearchForMessagesIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>搜尋Meshtastic訊息</string>
|
||||
<string>尋找Meshtastic訊息</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>IntentName</key>
|
||||
<string>INSetMessageAttributeIntent</string>
|
||||
<key>IntentExamples</key>
|
||||
<array>
|
||||
<string>將Meshtastic訊息標記為已讀</string>
|
||||
<string>標記Meshtastic訊息為已讀</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
176
MeshtasticTests/CarPlayTests.swift
Normal file
176
MeshtasticTests/CarPlayTests.swift
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
//
|
||||
// CarPlayTests.swift
|
||||
// MeshtasticTests
|
||||
//
|
||||
// Copyright(c) Garth Vander Houwen 4/16/26.
|
||||
//
|
||||
|
||||
import CarPlay
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Intents
|
||||
import Testing
|
||||
|
||||
@testable import Meshtastic
|
||||
|
||||
// MARK: - CarPlaySceneDelegate Tests
|
||||
|
||||
@Suite("CarPlaySceneDelegate")
|
||||
struct CarPlaySceneDelegateTests {
|
||||
|
||||
@Test func initialState() {
|
||||
let delegate = CarPlaySceneDelegate()
|
||||
#expect(delegate.interfaceController == nil)
|
||||
}
|
||||
|
||||
@Test func disconnectClearsInterfaceController() {
|
||||
let delegate = CarPlaySceneDelegate()
|
||||
// Simulate that interface controller was set during connect
|
||||
delegate.interfaceController = nil
|
||||
#expect(delegate.interfaceController == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CarPlayIntentDonation Tests
|
||||
|
||||
@Suite("CarPlayIntentDonation")
|
||||
struct CarPlayIntentDonationTests {
|
||||
|
||||
// MARK: - channelDisplayName
|
||||
|
||||
@Test func channelDisplayNamePrimary() {
|
||||
let name = CarPlayIntentDonation.testChannelDisplayName(for: 0)
|
||||
#expect(name == "Primary Channel")
|
||||
}
|
||||
|
||||
@Test func channelDisplayNameSecondary() {
|
||||
let name = CarPlayIntentDonation.testChannelDisplayName(for: 1)
|
||||
#expect(name == "Channel 1")
|
||||
}
|
||||
|
||||
@Test func channelDisplayNameHighIndex() {
|
||||
let name = CarPlayIntentDonation.testChannelDisplayName(for: 7)
|
||||
#expect(name == "Channel 7")
|
||||
}
|
||||
|
||||
// MARK: - mePerson
|
||||
|
||||
@Test func mePersonIsMe() {
|
||||
let me = CarPlayIntentDonation.testMePerson()
|
||||
#expect(me.isMe)
|
||||
#expect(me.displayName == "Me")
|
||||
#expect(me.personHandle?.value == "me")
|
||||
}
|
||||
|
||||
// MARK: - Outgoing DM Intent Structure
|
||||
|
||||
@Test func outgoingDMIntentHasCorrectConversationId() {
|
||||
let intent = CarPlayIntentDonation.testBuildOutgoingIntent(
|
||||
content: "Hello mesh",
|
||||
toUserNum: 1234567890,
|
||||
channel: 0
|
||||
)
|
||||
#expect(intent.conversationIdentifier == "dm-1234567890")
|
||||
#expect(intent.serviceName == "Meshtastic")
|
||||
#expect(intent.content == "Hello mesh")
|
||||
#expect(intent.recipients?.count == 1)
|
||||
#expect(intent.speakableGroupName == nil)
|
||||
}
|
||||
|
||||
@Test func outgoingChannelIntentHasCorrectConversationId() {
|
||||
let intent = CarPlayIntentDonation.testBuildOutgoingIntent(
|
||||
content: "Channel message",
|
||||
toUserNum: 0,
|
||||
channel: 2
|
||||
)
|
||||
#expect(intent.conversationIdentifier == "channel-2")
|
||||
#expect(intent.serviceName == "Meshtastic")
|
||||
#expect(intent.content == "Channel message")
|
||||
#expect(intent.recipients == nil)
|
||||
#expect(intent.speakableGroupName?.spokenPhrase == "Channel 2")
|
||||
}
|
||||
|
||||
@Test func outgoingPrimaryChannelIntentName() {
|
||||
let intent = CarPlayIntentDonation.testBuildOutgoingIntent(
|
||||
content: "Test",
|
||||
toUserNum: 0,
|
||||
channel: 0
|
||||
)
|
||||
#expect(intent.speakableGroupName?.spokenPhrase == "Primary Channel")
|
||||
}
|
||||
|
||||
// MARK: - Interaction Direction
|
||||
|
||||
@Test func outgoingInteractionDirection() {
|
||||
let interaction = CarPlayIntentDonation.testBuildOutgoingInteraction(
|
||||
content: "Test",
|
||||
toUserNum: 999,
|
||||
channel: 0
|
||||
)
|
||||
#expect(interaction.direction == .outgoing)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers Extension
|
||||
|
||||
extension CarPlayIntentDonation {
|
||||
|
||||
/// Exposes channelDisplayName for testing
|
||||
static func testChannelDisplayName(for index: Int32) -> String {
|
||||
channelDisplayName(for: index)
|
||||
}
|
||||
|
||||
/// Exposes mePerson for testing
|
||||
static func testMePerson() -> INPerson {
|
||||
mePerson()
|
||||
}
|
||||
|
||||
/// Builds an outgoing INSendMessageIntent without donating
|
||||
static func testBuildOutgoingIntent(content: String, toUserNum: Int64, channel: Int32) -> INSendMessageIntent {
|
||||
let me = mePerson()
|
||||
|
||||
if toUserNum != 0 {
|
||||
let handleValue = "\(toUserNum)@meshtastic.local"
|
||||
let recipientHandle = INPersonHandle(value: handleValue, type: .emailAddress)
|
||||
let recipient = INPerson(
|
||||
personHandle: recipientHandle,
|
||||
nameComponents: nil,
|
||||
displayName: "Node \(toUserNum.toHex())",
|
||||
image: nil,
|
||||
contactIdentifier: String(toUserNum),
|
||||
customIdentifier: String(toUserNum)
|
||||
)
|
||||
return INSendMessageIntent(
|
||||
recipients: [recipient],
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: content,
|
||||
speakableGroupName: nil,
|
||||
conversationIdentifier: "dm-\(toUserNum)",
|
||||
serviceName: "Meshtastic",
|
||||
sender: me,
|
||||
attachments: nil
|
||||
)
|
||||
} else {
|
||||
let channelName = channelDisplayName(for: channel)
|
||||
let groupName = INSpeakableString(spokenPhrase: channelName)
|
||||
return INSendMessageIntent(
|
||||
recipients: nil,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: content,
|
||||
speakableGroupName: groupName,
|
||||
conversationIdentifier: "channel-\(channel)",
|
||||
serviceName: "Meshtastic",
|
||||
sender: me,
|
||||
attachments: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an outgoing INInteraction without donating
|
||||
static func testBuildOutgoingInteraction(content: String, toUserNum: Int64, channel: Int32) -> INInteraction {
|
||||
let intent = testBuildOutgoingIntent(content: content, toUserNum: toUserNum, channel: channel)
|
||||
let interaction = INInteraction(intent: intent, response: nil)
|
||||
interaction.direction = .outgoing
|
||||
return interaction
|
||||
}
|
||||
}
|
||||
|
|
@ -364,7 +364,7 @@ struct InvalidVersionTests {
|
|||
}
|
||||
|
||||
@Test func viewCreationWithEmptyVersions() {
|
||||
let view = InvalidVersion()
|
||||
let view = InvalidVersion(minimumVersion: "", version: "")
|
||||
#expect(view.minimumVersion == "")
|
||||
#expect(view.version == "")
|
||||
}
|
||||
|
|
|
|||
181
MeshtasticTests/DeviceOnboardingTests.swift
Normal file
181
MeshtasticTests/DeviceOnboardingTests.swift
Normal file
|
|
@ -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<DeviceOnboarding.SetupGuide>()
|
||||
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])
|
||||
}
|
||||
}
|
||||
22
README.md
22
README.md
|
|
@ -122,6 +122,28 @@ Each settings item has an associated deep link. No parameters are supported for
|
|||
| `meshtastic:///settings/appFiles` | App Files |
|
||||
| `meshtastic:///settings/firmwareUpdates` | Firmware Updates |
|
||||
|
||||
## CarPlay
|
||||
|
||||
The app supports CarPlay for hands-free mesh messaging while driving. The CarPlay interface shows connection status, favorite contacts, and channels.
|
||||
|
||||
### Siri Voice Commands
|
||||
|
||||
Use these Siri voice commands on CarPlay to interact with Meshtastic:
|
||||
|
||||
| Intent | Example Phrase |
|
||||
| --- | --- |
|
||||
| `INSendMessageIntent` | "Send a message on Meshtastic" |
|
||||
| `INSearchForMessagesIntent` | "Search Meshtastic messages" |
|
||||
| `INSetMessageAttributeIntent` | "Mark Meshtastic message as read" |
|
||||
|
||||
### Features
|
||||
|
||||
- **Connection Status** — Shows whether a Meshtastic device is connected and the device name
|
||||
- **Favorite Contacts** — Lists nodes marked as favorites with unread message counts; tap to view contact detail with a native Siri compose button
|
||||
- **Channels** — Lists configured channels with unread counts; tap to start a channel message via Siri
|
||||
- **Incoming Message Notifications** — Siri announces incoming Meshtastic messages when Announce Notifications is enabled
|
||||
- **Conversation History** — Sent and received messages appear in CarPlay Messages for quick access
|
||||
|
||||
## Release Process
|
||||
|
||||
For more information on how a new release of Meshtastic is managed, please refer to [RELEASING.md](./RELEASING.md)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ struct MeshActivityAttributes: ActivityAttributes {
|
|||
// Fixed non-changing properties about your activity go here!
|
||||
var nodeNum: Int
|
||||
var name: String
|
||||
var shortName: String
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ struct WidgetsLiveActivity: Widget {
|
|||
|
||||
ActivityConfiguration(for: MeshActivityAttributes.self) { context in
|
||||
LiveActivityView(nodeName: context.attributes.name,
|
||||
uptimeSeconds: 0, // context.attributes.uptimeSeconds,
|
||||
uptimeSeconds: context.state.uptimeSeconds,
|
||||
channelUtilization: context.state.channelUtilization,
|
||||
airtime: context.state.airtime,
|
||||
sentPackets: context.state.sentPackets,
|
||||
|
|
@ -31,18 +31,16 @@ struct WidgetsLiveActivity: Widget {
|
|||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
if context.state.totalNodes > 0 {
|
||||
Text(" \(context.state.nodesOnline) online")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
} else {
|
||||
Text(" ")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
}
|
||||
Text("Ch. Util: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%")
|
||||
Text(context.attributes.shortName)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize()
|
||||
Text("Sent: \(context.state.sentPackets)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("ChUtil: \(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
|
|
@ -50,10 +48,6 @@ struct WidgetsLiveActivity: Widget {
|
|||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Sent: \(context.state.sentPackets)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Received: \(context.state.receivedPackets)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
|
|
@ -64,52 +58,95 @@ struct WidgetsLiveActivity: Widget {
|
|||
.tint(Color("LightIndigo"))
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing, priority: 1) {
|
||||
Spacer()
|
||||
if context.state.totalNodes > 0 {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(context.state.nodesOnline)/\(context.state.totalNodes)")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
Text("Bad: \(context.state.badReceivedPackets)")
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Dupe: \(context.state.dupeReceivedPackets)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Relayed: \(context.state.packetsSentRelay)")
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Relay Cancel: \(context.state.packetsCanceledRelay)")
|
||||
.font(.caption)
|
||||
Text("Relayed: \(context.state.packetsSentRelay)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
Text("Rly Cancel: \(context.state.packetsCanceledRelay)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
Text("Last Heard: \(Date().formatted())")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
.fixedSize()
|
||||
HStack(spacing: 4) {
|
||||
if let uptime = context.state.uptimeSeconds, uptime > 0 {
|
||||
Text("UPTIME:")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tint)
|
||||
Text(uptime >= 3600 ? "\(uptime / 3600)h \((uptime % 3600) / 60)m" : "\((uptime % 3600) / 60)m")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
Text("•")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
Text("UPDATED:")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tint)
|
||||
Text("\(Date().formatted(date: .omitted, time: .shortened))")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
|
||||
} compactLeading: {
|
||||
Image("m-logo-black")
|
||||
.resizable()
|
||||
.frame(width: 25)
|
||||
.padding(4)
|
||||
.background(.green.gradient, in: ContainerRelativeShape())
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.green)
|
||||
if context.state.totalNodes > 0 {
|
||||
Text("\(context.state.nodesOnline)")
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
.fixedSize()
|
||||
} compactTrailing: {
|
||||
Text(timerInterval: context.state.timerRange, countsDown: true)
|
||||
.monospacedDigit()
|
||||
.foregroundColor(Color("LightIndigo"))
|
||||
.frame(width: 40)
|
||||
Text("\(context.state.channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize()
|
||||
} minimal: {
|
||||
Image("m-logo-black")
|
||||
.resizable()
|
||||
.frame(width: 24.0)
|
||||
.padding(4)
|
||||
.background(.green.gradient, in: ContainerRelativeShape())
|
||||
ZStack {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.green)
|
||||
if context.state.totalNodes > 0 {
|
||||
Text("\(context.state.nodesOnline)")
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.offset(y: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentMargins(.trailing, 32, for: .expanded)
|
||||
.contentMargins([.leading, .top, .bottom], 6, for: .compactLeading)
|
||||
.contentMargins(.leading, 16, for: .expanded)
|
||||
.contentMargins(.trailing, 16, for: .expanded)
|
||||
.contentMargins(.all, 6, for: .compactLeading)
|
||||
.contentMargins(.all, 6, for: .compactTrailing)
|
||||
.contentMargins(.all, 6, for: .minimal)
|
||||
.widgetURL(URL(string: "meshtastic:///connect"))
|
||||
}
|
||||
|
|
@ -117,7 +154,7 @@ struct WidgetsLiveActivity: Widget {
|
|||
}
|
||||
|
||||
struct WidgetsLiveActivity_Previews: PreviewProvider {
|
||||
static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G")
|
||||
static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G", shortName: "8E6G")
|
||||
static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100, packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300))
|
||||
|
||||
static var previews: some View {
|
||||
|
|
@ -154,108 +191,122 @@ struct LiveActivityView: View {
|
|||
var timerRange: ClosedRange<Date>
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image(colorScheme == .light ? "m-logo-black" : "m-logo-white")
|
||||
.resizable()
|
||||
.clipShape(ContainerRelativeShape())
|
||||
.opacity(isLuminanceReduced ? 0.5 : 1.0)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(minWidth: 25, idealWidth: 45, maxWidth: 55)
|
||||
Spacer()
|
||||
NodeInfoView(isLuminanceReduced: _isLuminanceReduced, nodeName: nodeName, uptimeSeconds: uptimeSeconds, channelUtilization: channelUtilization, airtime: airtime, sentPackets: sentPackets, receivedPackets: receivedPackets, badReceivedPackets: badReceivedPackets,
|
||||
dupeReceivedPackets: dupeReceivedPackets, packetsSentRelay: packetsSentRelay, packetsCanceledRelay: packetsCanceledRelay, nodesOnline: nodesOnline, timerRange: timerRange)
|
||||
Spacer()
|
||||
}
|
||||
.tint(.primary)
|
||||
.padding([.leading, .top, .bottom])
|
||||
.padding(.trailing, 25)
|
||||
.activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed"))
|
||||
.activitySystemActionForegroundColor(.primary)
|
||||
}
|
||||
}
|
||||
let errorRate = receivedPackets > 0
|
||||
? (Double(badReceivedPackets) / Double(receivedPackets)) * 100
|
||||
: 0.0
|
||||
let now = Date()
|
||||
|
||||
struct NodeInfoView: View {
|
||||
@Environment(\.isLuminanceReduced) var isLuminanceReduced
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Header row: logo + node name + nodes online
|
||||
HStack(spacing: 6) {
|
||||
Image("m-logo-white")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 24, height: 24)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
Text(nodeName)
|
||||
.font(.callout)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.tint)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if totalNodes > 0 {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(nodesOnline)/\(totalNodes)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
var nodeName: String
|
||||
var uptimeSeconds: UInt32?
|
||||
var channelUtilization: Float?
|
||||
var airtime: Float?
|
||||
var sentPackets: UInt32
|
||||
var receivedPackets: UInt32
|
||||
var badReceivedPackets: UInt32
|
||||
var dupeReceivedPackets: UInt32
|
||||
var packetsSentRelay: UInt32
|
||||
var packetsCanceledRelay: UInt32
|
||||
var nodesOnline: UInt32
|
||||
var timerRange: ClosedRange<Date>
|
||||
// Stats grid — two columns
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
StatRow(label: "Ch. Utilization", value: "\(channelUtilization?.formatted(.number.precision(.fractionLength(1))) ?? "--")%")
|
||||
StatRow(label: "Airtime", value: "\(airtime?.formatted(.number.precision(.fractionLength(1))) ?? "--")%")
|
||||
StatRow(label: "Sent", value: "\(sentPackets)")
|
||||
StatRow(label: "Received", value: "\(receivedPackets)")
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
StatRow(label: "Error Rate", value: "\(errorRate.formatted(.number.precision(.fractionLength(1))))%")
|
||||
StatRow(label: "Relayed", value: "\(packetsSentRelay)")
|
||||
StatRow(label: "Relay Canceled", value: "\(packetsCanceledRelay)")
|
||||
StatRow(label: "Duplicate", value: "\(dupeReceivedPackets)")
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
|
||||
var body: some View {
|
||||
let errorRate = (Double(badReceivedPackets) / Double(receivedPackets)) * 100
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(nodeName)
|
||||
.font(nodeName.count > 14 ? .callout : .title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.tint)
|
||||
// Text("\(channelUtilization.map { String(format: "Ch. Util: %.2f", $0 ) } ?? "--")% \(airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%")
|
||||
Text("Ch. Util: \(channelUtilization?.formatted(.number.precision(.fractionLength(2))) ?? Constants.nilValueIndicator)%")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
Text("Packets: Sent \(sentPackets) Rec. \(receivedPackets)")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
Text("Bad: \(badReceivedPackets) Error Rate: \(errorRate.formatted(.number.precision(.fractionLength(2))))%")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
|
||||
Text("Connected: \(nodesOnline) nodes online")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
|
||||
let now = Date()
|
||||
Text("Last Heard: \(now.formatted())")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
// Footer: uptime + timer
|
||||
HStack {
|
||||
|
||||
if timerRange.upperBound >= now {
|
||||
Text("Next Update:")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
Spacer(minLength: 0)
|
||||
if let uptimeSeconds, uptimeSeconds > 0 {
|
||||
Text("Uptime:")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(uptimeText(uptimeSeconds))
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
Text("•")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if timerRange.upperBound >= now {
|
||||
Text("Update in:")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.8 : 1.0)
|
||||
.fixedSize()
|
||||
Text(timerInterval: timerRange, countsDown: true)
|
||||
.monospacedDigit()
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.tint)
|
||||
} else {
|
||||
Text("Not Connected")
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.caption)
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.tint(.primary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.activityBackgroundTint(colorScheme == .light ? Color("LiveActivityBackground") : Color("AccentColorDimmed"))
|
||||
.activitySystemActionForegroundColor(.primary)
|
||||
}
|
||||
|
||||
private func uptimeText(_ seconds: UInt32) -> String {
|
||||
let hours = seconds / 3600
|
||||
let minutes = (seconds % 3600) / 60
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(minutes)m"
|
||||
}
|
||||
return "\(minutes)m"
|
||||
}
|
||||
}
|
||||
|
||||
struct StatRow: View {
|
||||
var label: String
|
||||
var value: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,27 +316,26 @@ struct TimerView: View {
|
|||
var timerRange: ClosedRange<Date>
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
VStack(alignment: .center, spacing: 2) {
|
||||
Text("UPDATE IN")
|
||||
.font(.caption2)
|
||||
.allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/)
|
||||
.allowsTightening(true)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(isLuminanceReduced ? 0.5 : 1.0)
|
||||
Text(timerInterval: timerRange, countsDown: true)
|
||||
.monospacedDigit()
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(width: 80)
|
||||
.font(.callout)
|
||||
.frame(width: 60)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.tint)
|
||||
Image(systemName: "timer")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.resizable()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: 20, height: 20)
|
||||
.opacity(isLuminanceReduced ? 0.5 : 1.0)
|
||||
.offset(y: -5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue