diff --git a/StikDebug/Services/LocationSimulationKeepAlive.swift b/StikDebug/Services/LocationSimulationKeepAlive.swift new file mode 100644 index 0000000..0159257 --- /dev/null +++ b/StikDebug/Services/LocationSimulationKeepAlive.swift @@ -0,0 +1,204 @@ +// +// LocationSimulationKeepAlive.swift +// StikDebug +// + +import CoreLocation +import Foundation +import UIKit + +/// Owns the lifetime of an active simulated location, independently of any view. +/// +/// The device drops a simulated location if nothing keeps pushing it, so the +/// coordinate has to be re-sent periodically. That resend loop used to live in +/// `LocationSimulationView`'s `@State` as a main-runloop `Timer`, torn down by +/// `.onDisappear` — which tied the simulation's lifetime to the map screen being +/// visible and the app being foregrounded. Leaving the map tab, locking the +/// phone, or roughly thirty seconds of background were each enough to stop the +/// resends, let the simulation connection die, and snap the device back to its +/// real location. +/// +/// This keeps the coordinate outside the view, resends it from a dispatch timer +/// rather than the main runloop, holds the keep-alive services for as long as a +/// simulation is active, and renews its background assertion instead of letting +/// it expire. +final class LocationSimulationKeepAlive { + static let shared = LocationSimulationKeepAlive() + + /// The device drops the simulation if it goes unrefreshed; this matches the + /// interval the view's timer used. + private static let resendInterval: TimeInterval = 4 + + private let stateLock = NSLock() + private let timerQueue = DispatchQueue(label: "com.stik.location-keepalive") + + private var coordinate: CLLocationCoordinate2D? + private var timer: DispatchSourceTimer? + private var isHolding = false + + /// Main-thread only, mirroring `DebugKeepAliveLease`. + private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + + private init() {} + + var isActive: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return coordinate != nil + } + + var activeCoordinate: CLLocationCoordinate2D? { + stateLock.lock() + defer { stateLock.unlock() } + return coordinate + } + + /// Begin holding `newCoordinate`, or retarget an existing hold. + /// + /// Safe to call repeatedly: the keep-alive is acquired once and the resend + /// timer is created once, no matter how many times the coordinate changes. + func hold(_ newCoordinate: CLLocationCoordinate2D) { + stateLock.lock() + let isFirstHold = coordinate == nil + coordinate = newCoordinate + stateLock.unlock() + + acquireKeepAlive() + startTimer() + + if isFirstHold { + LogManager.shared.addInfoLog("Holding simulated location (resending every \(Int(Self.resendInterval))s)") + } + } + + /// Stop holding a simulated location and release the keep-alive. + /// + /// This does **not** clear the simulation on the device — callers that want + /// the real location back must still call `clear_simulated_location()`. + func release() { + stateLock.lock() + let wasActive = coordinate != nil + coordinate = nil + timer?.cancel() + timer = nil + stateLock.unlock() + + releaseKeepAlive() + + if wasActive { + LogManager.shared.addInfoLog("Released simulated location hold") + } + } + + // MARK: - Resend loop + + private func startTimer() { + // Built and installed under the lock so a losing racer never creates a + // source it has to throw away — a timer source starts suspended, and + // deallocating a suspended source traps. + stateLock.lock() + guard timer == nil else { + stateLock.unlock() + return + } + + let source = DispatchSource.makeTimerSource(queue: timerQueue) + source.schedule( + deadline: .now() + Self.resendInterval, + repeating: Self.resendInterval, + leeway: .milliseconds(500) + ) + source.setEventHandler { [weak self] in + self?.resend() + } + timer = source + stateLock.unlock() + + source.resume() + } + + private func resend() { + guard let target = activeCoordinate else { return } + + // `simulate_location` reuses its cached connection and rebuilds it if the + // set fails, so a dropped tunnel (e.g. a Wi-Fi<->cellular handoff) heals + // on the next tick. A failure here is expected and transient; the next + // resend retries. + LocationSimulationCommandQueue.shared.async { + _ = simulate_location( + DeviceConnectionContext.targetIPAddress, + target.latitude, + target.longitude, + PairingFileStore.prepareURL().path + ) + } + } + + // MARK: - Keep-alive + + private func acquireKeepAlive() { + stateLock.lock() + let alreadyHolding = isHolding + isHolding = true + stateLock.unlock() + + guard !alreadyHolding else { return } + + runOnMain { + // Forced holds: an active simulation must keep the app running even + // if the user's keep-alive toggles are off, otherwise iOS suspends us + // and the resends stop. + BackgroundAudioManager.shared.requestStart(force: true) + BackgroundLocationManager.shared.requestStart(force: true) + self.beginBackgroundTask() + } + } + + private func releaseKeepAlive() { + stateLock.lock() + let wasHolding = isHolding + isHolding = false + stateLock.unlock() + + guard wasHolding else { return } + + runOnMain { + BackgroundAudioManager.shared.requestStop(force: true) + BackgroundLocationManager.shared.requestStop(force: true) + self.endBackgroundTask() + } + } + + // MARK: - Background task (main-thread only) + + private func beginBackgroundTask() { + endBackgroundTask() + backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugLocationSimulation") { [weak self] in + guard let self else { return } + // Silent audio is what actually sustains background execution, so do + // not tear the hold down when this assertion expires — take a fresh + // one and keep resending. + if self.isActive { + LogManager.shared.addInfoLog("Location simulation background window renewed") + self.beginBackgroundTask() + } else { + self.endBackgroundTask() + } + } + } + + private func endBackgroundTask() { + if backgroundTaskID != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTaskID) + backgroundTaskID = .invalid + } + } + + private func runOnMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } +} diff --git a/StikDebug/Views/MainTabView.swift b/StikDebug/Views/MainTabView.swift index 17fbb16..27625fc 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,6 +224,14 @@ struct MainTabView: View { DispatchQueue.main.async { if code == 0 { + // Hold it, so a URL-scheme simulation survives backgrounding + // the same way one set from the map does. + LocationSimulationKeepAlive.shared.hold( + CLLocationCoordinate2D( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + ) BackgroundLocationManager.shared.requestStart() LogManager.shared.addInfoLog( String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude) @@ -239,6 +248,9 @@ struct MainTabView: View { } private func clearSimulatedLocation() { + // Release before clearing: a queued resend would otherwise re-apply the + // location a few seconds after it was cleared. + LocationSimulationKeepAlive.shared.release() LocationSimulationCommandQueue.shared.async { let code = clear_simulated_location() DispatchQueue.main.async { diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 037f9f4..aa1fb52 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -728,7 +728,6 @@ struct LocationSimulationView: View { @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? @@ -1025,6 +1024,14 @@ struct LocationSimulationView: View { } .onAppear { loadBookmarks() + // The hold outlives this view now, so @State can come back empty + // while a simulation is still running. Re-adopt it so the UI shows + // the active location instead of looking idle. + if simulatedCoordinate == nil, + let held = LocationSimulationKeepAlive.shared.activeCoordinate { + simulatedCoordinate = held + coordinate = held + } } .onDisappear { routeLoadTask?.cancel() @@ -1032,7 +1039,9 @@ struct LocationSimulationView: View { routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask = nil cancelRoutePlayback(resetMarker: true) - stopResendLoop() + // Deliberately does NOT stop the simulation. A held location has to + // outlive this screen — leaving the tab used to snap the device back + // to its real location. Only an explicit Clear releases the hold. if backgroundTaskID != .invalid { BackgroundLocationManager.shared.requestStop() } @@ -1379,18 +1388,13 @@ 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 } - LocationSimulationCommandQueue.shared.async { - _ = locationUpdateCode(for: simulatedCoordinate) - } - } + // Owned by the service, not this view: a main-runloop Timer stops when + // the app is suspended, and @State dies with the screen. + LocationSimulationKeepAlive.shared.hold(coordinate) } private func stopResendLoop() { - resendTimer?.invalidate() - resendTimer = nil + LocationSimulationKeepAlive.shared.release() simulatedCoordinate = nil }