diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift index 3a07ebc..bf7985a 100644 --- a/StikDebug/App/AppBootstrapper.swift +++ b/StikDebug/App/AppBootstrapper.swift @@ -11,7 +11,7 @@ enum AppBootstrapper { static func configure() { IdeviceLogFileManager.shared.prepareForLogging() registerDefaultSettings() - startConfiguredKeepAliveServices() + ensureKeepAliveSelection() applyDocumentPickerCopyWorkaround() } @@ -28,15 +28,10 @@ enum AppBootstrapper { ]) } - private static func startConfiguredKeepAliveServices() { + private static func ensureKeepAliveSelection() { let defaults = UserDefaults.standard - if defaults.bool(forKey: "keepAliveAudio") { - BackgroundAudioManager.shared.start() - } else if defaults.bool(forKey: "keepAliveLocation") { - BackgroundLocationManager.shared.start() - } else { + if !defaults.bool(forKey: "keepAliveAudio"), !defaults.bool(forKey: "keepAliveLocation") { defaults.set(true, forKey: "keepAliveAudio") - BackgroundAudioManager.shared.start() } } diff --git a/StikDebug/Services/BackgroundAudioManager.swift b/StikDebug/Services/BackgroundAudioManager.swift index c19f22b..2cbbdfe 100644 --- a/StikDebug/Services/BackgroundAudioManager.swift +++ b/StikDebug/Services/BackgroundAudioManager.swift @@ -11,7 +11,6 @@ final class BackgroundAudioManager { private var engine = AVAudioEngine() private var player = AVAudioPlayerNode() private var isRunning = false - private var persistentEnabled = false private var activityCount = 0 private var healthCheckTimer: Timer? @@ -30,13 +29,7 @@ final class BackgroundAudioManager { ) } - func start() { - persistentEnabled = true - refreshRunningState() - } - - func stop() { - persistentEnabled = false + func configurationDidChange() { refreshRunningState() } @@ -51,7 +44,7 @@ final class BackgroundAudioManager { } private func refreshRunningState() { - let shouldRun = persistentEnabled || (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveAudio")) + let shouldRun = activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveAudio") guard shouldRun != isRunning else { if shouldRun { recoverIfNeeded() diff --git a/StikDebug/Services/BackgroundLocationManager.swift b/StikDebug/Services/BackgroundLocationManager.swift index 589c0b7..a078f74 100644 --- a/StikDebug/Services/BackgroundLocationManager.swift +++ b/StikDebug/Services/BackgroundLocationManager.swift @@ -4,14 +4,15 @@ // import CoreLocation +import UIKit final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate { static let shared = BackgroundLocationManager() private let locationManager = CLLocationManager() private var isRunning = false - private var persistentEnabled = false private var activityCount = 0 + private var isRequestingAuthorization = false private override init() { super.init() @@ -22,13 +23,7 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate { locationManager.pausesLocationUpdatesAutomatically = false } - func start() { - persistentEnabled = true - refreshRunningState() - } - - func stop() { - persistentEnabled = false + func configurationDidChange() { refreshRunningState() } @@ -42,8 +37,22 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate { refreshRunningState() } + func requestAuthorizationIfNeeded() { + switch locationManager.authorizationStatus { + case .authorizedAlways, .authorizedWhenInUse: + return + case .notDetermined: + isRequestingAuthorization = true + locationManager.requestAlwaysAuthorization() + case .denied, .restricted: + restoreAudioFallback() + @unknown default: + restoreAudioFallback() + } + } + private func refreshRunningState() { - let shouldRun = persistentEnabled || (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveLocation")) + let shouldRun = activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveLocation") guard shouldRun != isRunning else { return } isRunning = shouldRun @@ -67,24 +76,47 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate { } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - guard isRunning else { return } switch manager.authorizationStatus { case .authorizedAlways, .authorizedWhenInUse: - manager.startUpdatingLocation() + isRequestingAuthorization = false + if isRunning { + manager.startUpdatingLocation() + } case .denied, .restricted: - restoreAudioFallback() + if isRunning || isRequestingAuthorization { + isRequestingAuthorization = false + restoreAudioFallback() + } case .notDetermined: return @unknown default: - restoreAudioFallback() + if isRunning || isRequestingAuthorization { + isRequestingAuthorization = false + restoreAudioFallback() + } } } private func restoreAudioFallback() { - persistentEnabled = false - isRunning = activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveLocation") - UserDefaults.standard.set(true, forKey: "keepAliveAudio") - BackgroundAudioManager.shared.start() + let defaults = UserDefaults.standard + defaults.set(false, forKey: "keepAliveLocation") + defaults.set(true, forKey: "keepAliveAudio") + refreshRunningState() + BackgroundAudioManager.shared.configurationDidChange() + + showAlert( + title: "Location Access Refused", + message: "Location access was refused. Open Settings to enable it.", + showOk: false, + showTryAgain: true, + primaryButtonText: "Settings" + ) { openSettings in + guard openSettings, + let settingsURL = URL(string: UIApplication.openSettingsURLString) else { + return + } + UIApplication.shared.open(settingsURL) + } } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { diff --git a/StikDebug/Services/LocationSimulationSession.swift b/StikDebug/Services/LocationSimulationSession.swift new file mode 100644 index 0000000..8fef9ae --- /dev/null +++ b/StikDebug/Services/LocationSimulationSession.swift @@ -0,0 +1,68 @@ +import Foundation +import CoreLocation +import Combine + +final class LocationSimulationSession: ObservableObject { + static let shared = LocationSimulationSession() + + @Published private(set) var isActive = false + @Published private(set) var coordinate: CLLocationCoordinate2D? + @Published private(set) var routeCoordinates: [CLLocationCoordinate2D]? + private var resendTimer: Timer? + private var routeID: UUID? + + private init() {} + + func start(at coordinate: CLLocationCoordinate2D) { + self.coordinate = coordinate + if !isActive { + isActive = true + BackgroundAudioManager.shared.requestStart() + BackgroundLocationManager.shared.requestStart() + } + } + + func startResending(at coordinate: CLLocationCoordinate2D, _ operation: @escaping () -> Void) { + start(at: coordinate) + resendTimer?.invalidate() + let timer = Timer(timeInterval: 4, repeats: true) { _ in operation() } + RunLoop.main.add(timer, forMode: .common) + resendTimer = timer + } + + func startRoute(at coordinate: CLLocationCoordinate2D, coordinates: [CLLocationCoordinate2D]) -> UUID { + let routeID = UUID() + self.routeID = routeID + routeCoordinates = coordinates + start(at: coordinate) + return routeID + } + + func isCurrentRoute(_ routeID: UUID) -> Bool { + self.routeID == routeID + } + + func clearRoute() { + routeID = nil + routeCoordinates = nil + } + + func pauseResending() { + resendTimer?.invalidate() + resendTimer = nil + } + + func updateCoordinate(_ coordinate: CLLocationCoordinate2D) { + self.coordinate = coordinate + } + + func stop() { + pauseResending() + clearRoute() + guard isActive else { return } + isActive = false + coordinate = nil + BackgroundAudioManager.shared.requestStop() + BackgroundLocationManager.shared.requestStop() + } +} diff --git a/StikDebug/Views/MainTabView.swift b/StikDebug/Views/MainTabView.swift index 17fbb16..f175b75 100644 --- a/StikDebug/Views/MainTabView.swift +++ b/StikDebug/Views/MainTabView.swift @@ -7,6 +7,7 @@ import SwiftUI import Foundation +import CoreLocation private enum ExternalLocationAction: Identifiable { case simulate(URL, Double, Double) @@ -223,7 +224,22 @@ struct MainTabView: View { DispatchQueue.main.async { if code == 0 { - BackgroundLocationManager.shared.requestStart() + LocationSimulationSession.shared.clearRoute() + LocationSimulationSession.shared.startResending( + at: CLLocationCoordinate2D( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + ) { + LocationSimulationCommandQueue.shared.async { + _ = simulate_location( + DeviceConnectionContext.targetIPAddress, + coordinate.latitude, + coordinate.longitude, + pairingFile.path + ) + } + } LogManager.shared.addInfoLog( String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude) ) @@ -243,7 +259,7 @@ struct MainTabView: View { let code = clear_simulated_location() DispatchQueue.main.async { if code == 0 { - BackgroundLocationManager.shared.requestStop() + LocationSimulationSession.shared.stop() LogManager.shared.addInfoLog("Cleared simulated location from URL") } else { showAlert( diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 037f9f4..95f46ce 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -724,11 +724,11 @@ final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCo } struct LocationSimulationView: View { + @ObservedObject private var simulationSession = LocationSimulationSession.shared @State private var coordinate: CLLocationCoordinate2D? @State private var position: MapCameraPosition = .userLocation(fallback: .automatic) @State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid - @State private var resendTimer: Timer? @State private var routeLoadTask: Task? @State private var routeSpeedPrefetchTask: Task? @State private var routePlaybackTask: Task? @@ -788,11 +788,11 @@ struct LocationSimulationView: View { } private var hasActiveSimulation: Bool { - simulatedCoordinate != nil || routePlaybackTask != nil + simulationSession.isActive || simulatedCoordinate != nil || routePlaybackTask != nil } private var isRouteRunning: Bool { - routePlaybackTask != nil + routePlaybackTask != nil || simulationSession.routeCoordinates != nil } private var hasRouteContext: Bool { @@ -801,7 +801,12 @@ struct LocationSimulationView: View { routePlan != nil || isLoadingRoute || isPrefetchingRouteSpeeds || - routePlaybackCoordinate != nil + routePlaybackCoordinate != nil || + simulationSession.routeCoordinates != nil + } + + private var displayedRouteCoordinate: CLLocationCoordinate2D? { + simulationSession.routeCoordinates == nil ? routePlaybackCoordinate : simulationSession.coordinate } private var routeSummaryText: String? { @@ -818,6 +823,9 @@ struct LocationSimulationView: View { } private var routeStatusText: String { + if simulationSession.routeCoordinates != nil { + return "Route simulation active." + } if isLoadingRoute { return "Calculating route…" } @@ -892,8 +900,8 @@ struct LocationSimulationView: View { Marker("End", coordinate: routeEndCoordinate) .tint(.red) } - if let routePlaybackCoordinate { - Marker("Current", coordinate: routePlaybackCoordinate) + if let displayedRouteCoordinate { + Marker("Current", coordinate: displayedRouteCoordinate) .tint(.blue) } } else if let coordinate { @@ -1025,17 +1033,28 @@ struct LocationSimulationView: View { } .onAppear { loadBookmarks() + if let routeCoordinates = simulationSession.routeCoordinates { + coordinate = nil + routePolyline = makeRoutePolyline(for: routeCoordinates) + routeStartSelection = routeCoordinates.first.map { + RouteSearchSelection(title: "Route Start", coordinate: $0) + } + routeEndSelection = routeCoordinates.last.map { + RouteSearchSelection(title: "Route End", coordinate: $0) + } + if let routePolyline { + position = .rect(routePolyline.boundingMapRect) + } + } else if let activeCoordinate = simulationSession.coordinate { + coordinate = activeCoordinate + simulatedCoordinate = activeCoordinate + } } .onDisappear { routeLoadTask?.cancel() routeLoadTask = nil routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = nil - cancelRoutePlayback(resetMarker: true) - stopResendLoop() - if backgroundTaskID != .invalid { - BackgroundLocationManager.shared.requestStop() - } endBackgroundTask() } } @@ -1297,14 +1316,14 @@ struct LocationSimulationView: View { ) { routePlaybackCoordinate = nil beginBackgroundTask() + LocationSimulationSession.shared.clearRoute() startResendLoop(with: coord) - BackgroundLocationManager.shared.requestStart() } } private func simulateRoute() { guard pairingExists, - routePlan != nil, + let routePlan, let firstCoordinate = routePlaybackSamples.first?.coordinate, !isBusy else { return @@ -1319,10 +1338,13 @@ struct LocationSimulationView: View { operation: { locationUpdateCode(for: firstCoordinate) } ) { beginBackgroundTask() - BackgroundLocationManager.shared.requestStart() + let routeID = LocationSimulationSession.shared.startRoute( + at: firstCoordinate, + coordinates: routePlan.displayCoordinates + ) simulatedCoordinate = nil routePlaybackCoordinate = firstCoordinate - startRoutePlayback() + startRoutePlayback(routeID: routeID) } } @@ -1356,13 +1378,14 @@ struct LocationSimulationView: View { routeSpeedPrefetchTask = nil cancelRoutePlayback(resetMarker: true) stopResendLoop() + LocationSimulationSession.shared.clearRoute() runLocationCommand( errorTitle: "Clear Failed", errorMessage: { code in "Could not clear simulated location (error \(code))." }, operation: clear_simulated_location ) { endBackgroundTask() - BackgroundLocationManager.shared.requestStop() + LocationSimulationSession.shared.stop() } } @@ -1379,18 +1402,15 @@ struct LocationSimulationView: View { private func startResendLoop(with coordinate: CLLocationCoordinate2D) { simulatedCoordinate = coordinate - resendTimer?.invalidate() - resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in - guard let simulatedCoordinate else { return } + LocationSimulationSession.shared.startResending(at: coordinate) { LocationSimulationCommandQueue.shared.async { - _ = locationUpdateCode(for: simulatedCoordinate) + _ = locationUpdateCode(for: coordinate) } } } private func stopResendLoop() { - resendTimer?.invalidate() - resendTimer = nil + LocationSimulationSession.shared.pauseResending() simulatedCoordinate = nil } @@ -1520,15 +1540,19 @@ struct LocationSimulationView: View { } } - private func startRoutePlayback() { + private func startRoutePlayback(routeID: UUID) { routePlaybackTask = Task { var lastSuccessfulCoordinate = routePlaybackSamples.first?.coordinate for sample in routePlaybackSamples.dropFirst() { try? await Task.sleep(for: .seconds(sample.delayFromPrevious)) - guard !Task.isCancelled else { return } + guard !Task.isCancelled, + LocationSimulationSession.shared.isCurrentRoute(routeID) else { + return + } let code = await sendLocationUpdate(for: sample.coordinate) + guard LocationSimulationSession.shared.isCurrentRoute(routeID) else { return } guard code == 0 else { await MainActor.run { routePlaybackTask = nil @@ -1546,10 +1570,12 @@ struct LocationSimulationView: View { lastSuccessfulCoordinate = sample.coordinate await MainActor.run { routePlaybackCoordinate = sample.coordinate + LocationSimulationSession.shared.updateCoordinate(sample.coordinate) } } await MainActor.run { + guard LocationSimulationSession.shared.isCurrentRoute(routeID) else { return } routePlaybackTask = nil if let lastSuccessfulCoordinate { routePlaybackCoordinate = lastSuccessfulCoordinate diff --git a/StikDebug/Views/SettingsView.swift b/StikDebug/Views/SettingsView.swift index bc32e50..871fe31 100644 --- a/StikDebug/Views/SettingsView.swift +++ b/StikDebug/Views/SettingsView.swift @@ -242,32 +242,22 @@ struct SettingsView: View { } private func handleAudioKeepAliveChange(_ enabled: Bool) { - if enabled { - BackgroundAudioManager.shared.start() - BackgroundLocationManager.shared.stop() - return - } - - if !keepAliveLocation { + if !enabled, !keepAliveLocation { keepAliveLocation = true + BackgroundLocationManager.shared.requestAuthorizationIfNeeded() } - BackgroundAudioManager.shared.stop() - BackgroundLocationManager.shared.start() + BackgroundAudioManager.shared.configurationDidChange() + BackgroundLocationManager.shared.configurationDidChange() } private func handleLocationKeepAliveChange(_ enabled: Bool) { if enabled { - if !keepAliveAudio { - BackgroundLocationManager.shared.start() - } - return - } - - BackgroundLocationManager.shared.stop() - if !keepAliveAudio { + BackgroundLocationManager.shared.requestAuthorizationIfNeeded() + } else if !keepAliveAudio { keepAliveAudio = true - BackgroundAudioManager.shared.start() } + BackgroundAudioManager.shared.configurationDidChange() + BackgroundLocationManager.shared.configurationDidChange() } // MARK: - Business Logic