diff --git a/Localizable.xcstrings b/Localizable.xcstrings index a5670f11..4fff08b0 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -12692,6 +12692,9 @@ } } } + }, + "Enable Location Sharing" : { + }, "Enable Notifications" : { "localizations" : { @@ -20848,9 +20851,6 @@ } } } - }, - "Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from Settings > App Settings > Open Settings." : { - }, "Meshtasticยฎ Copyright Meshtastic LLC" : { "localizations" : { @@ -33453,6 +33453,9 @@ } } } + }, + "Share Location" : { + }, "Share QR Code" : { "localizations" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 7d6f5b32..263cef01 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -1858,7 +1858,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.13; + MARKETING_VERSION = 2.6.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1891,7 +1891,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.13; + MARKETING_VERSION = 2.6.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTS_MACCATALYST = YES; @@ -1922,7 +1922,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.13; + MARKETING_VERSION = 2.6.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1954,7 +1954,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.13; + MARKETING_VERSION = 2.6.14; PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Meshtastic/Helpers/LocationsHandler.swift b/Meshtastic/Helpers/LocationsHandler.swift index 645959f3..ea1a05cc 100644 --- a/Meshtastic/Helpers/LocationsHandler.swift +++ b/Meshtastic/Helpers/LocationsHandler.swift @@ -2,14 +2,15 @@ // LocationsHandler.swift // Meshtastic // -// Created by Garth Vander Houwen on 12/4/23. +// Copyright Garth Vander Houwen 12/4/23. // import SwiftUI import CoreLocation import OSLog -// Shared state that manages the `CLLocationManager` and `CLBackgroundActivitySession`. +// The @MainActor annotation ensures that all state changes and UI updates happen on the main thread, +// preventing potential race conditions and crashes related to UI updates from background threads. @MainActor class LocationsHandler: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate { static let shared = LocationsHandler() // Create a single, shared instance of the object. @@ -34,46 +35,82 @@ import OSLog @Published var backgroundActivity: Bool = UserDefaults.standard.bool(forKey: "BGActivitySessionStarted") { didSet { + // Invalidate or create the background activity session based on the new value. backgroundActivity ? self.background = CLBackgroundActivitySession() : self.background?.invalidate() UserDefaults.standard.set(backgroundActivity, forKey: "BGActivitySessionStarted") } } // The continuation we will use to asynchronously ask the user permission to track their location. + // This is an Optional to ensure it can be nilled out after use. private var permissionContinuation: CheckedContinuation? + /// Requests "Always" location authorization from the user. + /// This method uses Swift's structured concurrency to await the user's decision. + /// - Returns: The `CLAuthorizationStatus` reflecting the user's choice. func requestLocationAlwaysPermissions() async -> CLAuthorizationStatus { return await withCheckedContinuation { continuation in + // Store the continuation. self.permissionContinuation = continuation + // Request authorization. The response will come via `locationManagerDidChangeAuthorization`. manager.requestAlwaysAuthorization() } } + /// Delegate method called when the location authorization status changes. + /// - Parameter manager: The CLLocationManager instance. func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - // This is the line you need to add - permissionContinuation?.resume(returning: manager.authorizationStatus) + // Ensure the continuation exists before attempting to resume it. + // If it's nil, it means either no request was pending or it was already resumed. + guard let continuation = permissionContinuation else { + Logger.services.debug("๐Ÿ“ [App] locationManagerDidChangeAuthorization called but no permissionContinuation is active.") + return + } + // Resume the continuation with the current authorization status. + continuation.resume(returning: manager.authorizationStatus) + // CRUCIAL: Nil out the continuation immediately after resuming it. + // This prevents attempting to resume the same continuation multiple times, + // which would lead to a runtime crash. + self.permissionContinuation = nil } - override init() { super.init() self.manager.delegate = self + // Allow background location updates for continuous tracking. self.manager.allowsBackgroundLocationUpdates = true + // Set desired accuracy for location updates. + // Consider your app's needs: kCLLocationAccuracyBestForNavigation, kCLLocationAccuracyBest, etc. + // For general tracking, kCLLocationAccuracyHundredMeters might be sufficient to save battery. + self.manager.desiredAccuracy = kCLLocationAccuracyBest + // Set the distance filter to only receive updates when the device has moved a certain distance. + self.manager.distanceFilter = kCLDistanceFilterNone // Receive all updates initially } func startLocationUpdates() { let status = self.manager.authorizationStatus + // Guard against starting updates without proper authorization. guard status == .authorizedAlways || status == .authorizedWhenInUse else { + Logger.services.warning("๐Ÿ“ [App] Cannot start location updates: insufficient authorization status: \(status.rawValue)") return } Logger.services.info("๐Ÿ“ [App] Starting location updates") - Task { + // Using a Task for asynchronous operations. The @MainActor isolation of the class + // ensures that all state changes within this Task (accessing @Published properties) + // will be performed on the main actor. + Task { @MainActor in do { self.updatesStarted = true + // `liveUpdates()` provides a stream of location updates. let updates = CLLocationUpdate.liveUpdates() for try await update in updates { - if !self.updatesStarted { break } + // Check for task cancellation to allow graceful stopping. + try Task.checkCancellation() + // If `updatesStarted` is set to false (e.g., by `stopLocationUpdates`), + // break out of the loop to stop processing updates. + if !self.updatesStarted { + Logger.services.info("๐Ÿ›‘ [App] Location updates loop stopped due to updatesStarted being false.") + break + } if let loc = update.location { self.isStationary = update.isStationary - - var locationAdded: Bool - locationAdded = addLocation(loc, smartPostion: enableSmartPosition) + let locationAdded = addLocation(loc, smartPostion: enableSmartPosition) if !isRecording && locationAdded { self.count = 1 } else if locationAdded && isRecording { @@ -81,18 +118,27 @@ import OSLog } } } + } catch is CancellationError { + // Handle explicit task cancellation gracefully. + Logger.services.info("๐Ÿ“ [App] Location updates task was cancelled.") } catch { + // Catch any other errors during location updates. Logger.services.error("๐Ÿ’ฅ [App] Could not start location updates: \(error.localizedDescription, privacy: .public)") } - return + // The Task completes implicitly here. } } - + /// Stops receiving live location updates. func stopLocationUpdates() { Logger.services.info("๐Ÿ›‘ [App] Stopping location updates") + // Setting `updatesStarted` to false will cause the `liveUpdates()` loop to break. self.updatesStarted = false } - + /// Adds a location to the array and updates tracking metrics, applying smart position filters if enabled. + /// - Parameters: + /// - location: The `CLLocation` object to add. + /// - smartPostion: A boolean indicating whether to apply smart position filtering. + /// - Returns: `true` if the location was added, `false` if it was filtered out by smart position. func addLocation(_ location: CLLocation, smartPostion: Bool) -> Bool { if smartPostion { let age = -location.timestamp.timeIntervalSinceNow @@ -104,6 +150,7 @@ import OSLog Logger.services.info("๐Ÿ“ [App] Smart Position - Bad Location: Horizontal Accuracy: \(location.horizontalAccuracy) \(location, privacy: .private(mask: .none))") return false } + // Consider adjusting this threshold based on your needs. 5 meters is quite strict. if location.horizontalAccuracy > 5 { Logger.services.info("๐Ÿ“ [App] Smart Position - Bad Location: Horizontal Accuracy: \(location.horizontalAccuracy) \(location, privacy: .private(mask: .none))") return false @@ -120,46 +167,56 @@ import OSLog } locationsArray.append(location) } else { + // If not recording, only keep the latest location. locationsArray = [location] } + // Store the last known location in UserDefaults for persistence. UserDefaults.standard.set(location.coordinate.latitude, forKey: "lastKnownLatitude") UserDefaults.standard.set(location.coordinate.longitude, forKey: "lastKnownLongitude") UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastKnownLocationTimestamp") return true } + // Default location (Apple Park) used as a fallback. static let DefaultLocation = CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090) + /// Provides the current location, falling back to last known or a default if necessary. static var currentLocation: CLLocationCoordinate2D { + // Attempt to get the most recent location from the manager. if let location = shared.manager.location { return location.coordinate } else { - // Check authorization status + // If manager.location is nil, check authorization status and potentially request. let status = shared.manager.authorizationStatus switch status { case .notDetermined: - Logger.services.info("๐Ÿ“ [App] Location permission not determined, requesting authorization") + Logger.services.info("๐Ÿ“ [App] Location permission not determined, requesting authorization (WhenInUse)") + // Requesting WhenInUse authorization here. For "Always" authorization, + // `requestLocationAlwaysPermissions()` should be called explicitly, + // typically from a user action or app setup. shared.manager.requestWhenInUseAuthorization() case .denied, .restricted: Logger.services.warning("๐Ÿ“ [App] Location access denied or restricted. Please enable location services in Settings to get accurate positioning!") + // Requesting WhenInUse authorization again, though user interaction is needed for denied/restricted. shared.manager.requestWhenInUseAuthorization() default: - break + break // For .authorizedAlways, .authorizedWhenInUse, .limited } - // Fallback 1: Last known location from UserDefaults (if within 4 hours) + // Fallback 1: Last known location from UserDefaults if it's recent (within 4 hours). if let lat = UserDefaults.standard.object(forKey: "lastKnownLatitude") as? Double, - let lon = UserDefaults.standard.object(forKey: "lastKnownLongitude") as? Double, - let timestamp = UserDefaults.standard.object(forKey: "lastKnownLocationTimestamp") as? Double, - lat >= -90 && lat <= 90, - lon >= -180 && lon <= 180, + let lon = UserDefaults.standard.object(forKey: "lastKnownLongitude") as? Double, + let timestamp = UserDefaults.standard.object(forKey: "lastKnownLocationTimestamp") as? Double, + lat >= -90 && lat <= 90, // Validate latitude + lon >= -180 && lon <= 180, // Validate longitude Date().timeIntervalSince1970 - timestamp <= 14_400 { // 4 hours in seconds Logger.services.info("๐Ÿ“ [App] Falling back to last known location (age: \(Int(Date().timeIntervalSince1970 - timestamp)) seconds)") return CLLocationCoordinate2D(latitude: lat, longitude: lon) } - // Fallback 2: Default location + // Fallback 2: Default location if no other location is available. Logger.services.warning("๐Ÿ“ [App] No Location and no last known location, something is really wrong. Teleporting user to Apple Park") return DefaultLocation } } - + /// Estimates the number of satellites in view based on horizontal and vertical accuracy. + /// This is a heuristic and not a direct report of satellite count. static var satsInView: Int { var sats = 0 if let newLocation = shared.locationsArray.last { @@ -185,5 +242,4 @@ import OSLog } return sats } - } diff --git a/Meshtastic/MeshtasticApp.swift b/Meshtastic/MeshtasticApp.swift index 19a001e1..4be7b576 100644 --- a/Meshtastic/MeshtasticApp.swift +++ b/Meshtastic/MeshtasticApp.swift @@ -43,7 +43,7 @@ struct MeshtasticAppleApp: App { env: environment, site: .us5 ), - trackingConsent: UserDefaults.usageDataAndCrashReporting ? .granted : .notGranted, + trackingConsent: UserDefaults.usageDataAndCrashReporting ? .granted : .notGranted ) DatadogCrashReporting.CrashReporting.enable() Logs.enable() diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index 24835516..aeda1cfb 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -115,30 +115,32 @@ struct NodeDetail: View { .textSelection(.enabled) } .accessibilityElement(children: .combine) - if node.user?.keyMatch ?? false { - if let publicKey = node.user?.publicKey { - HStack { - Label { - Text("Public Key") - } icon: { - Image(systemName: "lock.fill") - .foregroundColor(.green) - } - Spacer() - Button(action: { - context.perform { - UIPasteboard.general.string = publicKey.base64EncodedString() + let connectedNode = getNodeInfo(id: BLEManager.shared.connectedPeripheral?.num ?? 0, context: context) + if let user = node.user, user.keyMatch { + let publicKey = node.num == connectedNode?.num + ? node.securityConfig?.publicKey?.base64EncodedString() ?? "" + : user.publicKey?.base64EncodedString() ?? "" + HStack { + Label { + Text("Public Key") + } icon: { + Image(systemName: "lock.fill") + .foregroundColor(.green) } - }) { - HStack { - Image(systemName: "key.horizontal.fill") - Text("Copy") + Spacer() + Button(action: { + context.perform { + UIPasteboard.general.string = publicKey + } + }) { + HStack { + Image(systemName: "key.horizontal.fill") + Text("Copy") + } } } + .accessibilityElement(children: .combine) } - .accessibilityElement(children: .combine) - } - } if let metadata = node.metadata { HStack { Label { diff --git a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift index e9ce4593..c1a73ebc 100644 --- a/Meshtastic/Views/Onboarding/DeviceOnboarding.swift +++ b/Meshtastic/Views/Onboarding/DeviceOnboarding.swift @@ -13,9 +13,9 @@ struct DeviceOnboarding: View { @EnvironmentObject var bleManager: BLEManager @State var navigationPath: [SetupGuide] = [] @State var locationStatus = LocationsHandler.shared.manager.authorizationStatus - + @AppStorage("provideLocation") private var provideLocation: Bool = false + @AppStorage("provideLocationInterval") private var provideLocationInterval: Int = 30 @Environment(\.dismiss) var dismiss - /// The Title View var title: some View { VStack { @@ -41,20 +41,18 @@ struct DeviceOnboarding: View { VStack(alignment: .leading, spacing: 16) { makeRow( icon: "antenna.radiowaves.left.and.right", - title: "Stay Connected Anywhere", - subtitle: "Communicate off-the-grid with your friends and community without cell service." + title: "Stay Connected Anywhere".localized, + subtitle: "Communicate off-the-grid with your friends and community without cell service.".localized ) - makeRow( icon: "point.3.connected.trianglepath.dotted", - title: "Create Your Own Networks", - subtitle: "Easily set up private mesh networks for secure and reliable communication in remote areas." + title: "Create Your Own Networks".localized, + subtitle: "Easily set up private mesh networks for secure and reliable communication in remote areas.".localized ) - makeRow( icon: "location", - title: "Track and Share Locations", - subtitle: "Share your location in real-time and keep your group coordinated with integrated GPS features." + title: "Track and Share Locations".localized, + subtitle: "Share your location in real-time and keep your group coordinated with integrated GPS features.".localized ) } .padding() @@ -78,8 +76,8 @@ struct DeviceOnboarding: View { } var notificationView: some View { - ScrollView(.vertical) { - VStack { + VStack { + ScrollView(.vertical) { VStack { Text("App Notifications") .font(.largeTitle.bold()) @@ -94,18 +92,18 @@ struct DeviceOnboarding: View { .fixedSize(horizontal: false, vertical: true) makeRow( icon: "message", - title: "Incoming Messages", - subtitle: "Meshtastic notifications for channel messages and direct messages" + title: "Incoming Messages".localized, + subtitle: "Notifications for channel and direct messages.".localized ) makeRow( icon: "flipphone", - title: "New Nodes", - subtitle: "Allow Meshtastic to send notifications for messages, newly discovered nodes and low battery alerts for the connected device." + title: "New Nodes".localized, + subtitle: "Notifications for newly discovered nodes.".localized ) makeRow( icon: "battery.25percent", - title: "Low Battery", - subtitle: "Allow Meshtastic to send notifications for messages, newly discovered nodes and low battery alerts for the connected device." + title: "Low Battery".localized, + subtitle: "Notifications for low battery alerts for the connected device.".localized ) Text("Critical Alerts") .font(.title2.bold()) @@ -113,31 +111,31 @@ struct DeviceOnboarding: View { .fixedSize(horizontal: false, vertical: true) makeRow( icon: "exclamationmark.triangle.fill", - subtitle: "Select packets sent as critical will ignore the mute switch and Do Not Disturb settings in the OS notification center." + subtitle: "Select packets sent as critical will ignore the mute switch and Do Not Disturb settings in the OS notification center.".localized ) } .padding() - Spacer() - Button { - Task { - await requestNotificationsPermissions() - await goToNextStep(after: .notifications) - } - } label: { - Text("Configure notification permissions") - .frame(maxWidth: .infinity) - } - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - .buttonStyle(.borderedProminent) } + Spacer() + Button { + Task { + await requestNotificationsPermissions() + await goToNextStep(after: .notifications) + } + } label: { + Text("Configure notification permissions") + .frame(maxWidth: .infinity) + } + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) } } var locationView: some View { - ScrollView(.vertical) { - VStack { + VStack { + ScrollView(.vertical) { VStack { Text("Phone Location") .font(.largeTitle.bold()) @@ -145,47 +143,62 @@ struct DeviceOnboarding: View { .fixedSize(horizontal: false, vertical: true) } VStack(alignment: .leading, spacing: 16) { - Text("Meshtastic uses your phone's location to enable a number of features. You can update your location permissions at any time from Settings > App Settings > Open Settings.") + Text(createLocationString()) .font(.body.bold()) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) makeRow( icon: "location", - title: "Share Location", - subtitle: "Use your phone GPS to send locations to your node to instead of using a hardware GPS on your node." + title: "Share Location".localized, + subtitle: "Use your phone GPS to send locations to your node to instead of using a hardware GPS on your node.".localized ) + Toggle(isOn: $provideLocation ) { + Label { + Text("Enable Location Sharing") + } icon: { + Image(systemName: "location.circle") + } + } + .fixedSize() + .scaleEffect(0.85) + .padding(.leading, 52) + .tint(.accentColor) + .onChange(of: provideLocation) { + UserDefaults.provideLocationInterval = 30 + UserDefaults.enableSmartPosition = true + } makeRow( icon: "lines.measurement.horizontal", - title: "Distance Measurements", - subtitle: "Used to display the distance between your phone and other Meshtastic nodes where positions are available." + title: "Distance Measurements".localized, + subtitle: "Display the distance between your phone and other Meshtastic nodes with positions.".localized ) makeRow( icon: "line.3.horizontal.decrease.circle", - title: "Distance Filters", - subtitle: "Filter the node list and mesh map based on proximity to your phone." + title: "Distance Filters".localized, + subtitle: "Filter the node list and mesh map based on proximity to your phone.".localized ) makeRow( icon: "mappin", title: "Mesh Map Location", - subtitle: "Enables the blue location dot for your phone in the mesh map." + subtitle: "Enables the blue location dot for your phone in the mesh map.".localized ) } .padding() - Spacer() - Button { - Task { - await requestLocationPermissions() - } - } label: { - Text("Configure Location Permissions") - .frame(maxWidth: .infinity) - } - .padding() - .buttonBorderShape(.capsule) - .controlSize(.large) - .padding() - .buttonStyle(.borderedProminent) } + Spacer() + Button { + Task { + await requestLocationPermissions() + } + } label: { + Text("Configure Location Permissions") + .frame(maxWidth: .infinity) + } + .padding() + .buttonBorderShape(.capsule) + .controlSize(.large) + .padding() + .buttonStyle(.borderedProminent) } } @@ -216,15 +229,14 @@ struct DeviceOnboarding: View { .symbolRenderingMode(.multicolor) .font(.subheadline) .aspectRatio(contentMode: .fit) - .padding() - .frame(width: 72, height: 72) - + .padding(.horizontal) + .padding(.vertical, 8) + .frame(width: 72, height: 60) VStack(alignment: .leading) { Text(title) .font(.subheadline.weight(.semibold)) .foregroundColor(.primary) .fixedSize(horizontal: false, vertical: true) - Text(subtitle) .font(.subheadline) .foregroundColor(.secondary) @@ -232,7 +244,6 @@ struct DeviceOnboarding: View { }.multilineTextAlignment(.leading) }.accessibilityElement(children: .combine) } - // MARK: Navigation func goToNextStep(after step: SetupGuide?) async { switch step { @@ -259,6 +270,16 @@ struct DeviceOnboarding: View { } } + // MARK: Formatting + func createLocationString() -> AttributedString { + var fullText = AttributedString("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: "settings") { + fullText[range].link = URL(string: UIApplication.openSettingsURLString)! + fullText[range].foregroundColor = .blue + } + return fullText + } + // MARK: Permission Checks func requestNotificationsPermissions() async { let center = UNUserNotificationCenter.current() diff --git a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift index ad3e5e3c..a02dd0ad 100644 --- a/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift +++ b/Meshtastic/Views/Settings/Config/Module/AmbientLightingConfig.swift @@ -19,10 +19,7 @@ struct AmbientLightingConfig: View { @State private var isPresentingSaveConfirm: Bool = false @State var hasChanges = false @State var ledState: Bool = false - @State var current = 10 - @State var red = 0 - @State var green = 0 - @State var blue = 0 + @State var current = 0 @State private var color = Color(red: 51, green: 199, blue: 88) // Color(.sRGB, red: 0.98, green: 0.9, blue: 0.2) @State private var components: Color.Resolved? var body: some View { @@ -60,6 +57,7 @@ struct AmbientLightingConfig: View { var al = ModuleConfig.AmbientLightingConfig() al.ledState = ledState al.current = UInt32(current) + components = color.resolve(in: environment) if let components { al.red = UInt32(components.red * 255) al.green = UInt32(components.green * 255) @@ -119,7 +117,7 @@ struct AmbientLightingConfig: View { } func setAmbientLightingConfigValue() { self.ledState = node?.ambientLightingConfig?.ledState ?? false - self.current = Int(node?.ambientLightingConfig?.current ?? 10) + self.current = Int(node?.ambientLightingConfig?.current ?? 0) let red = Double(node?.ambientLightingConfig?.red ?? 255) let green = Double(node?.ambientLightingConfig?.green ?? 255) let blue = Double(node?.ambientLightingConfig?.blue ?? 255)