mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
Keep a simulated location alive outside the map screen
The simulated location only held while the map screen was visible and the app was foregrounded. The resend loop was a main-runloop Timer in the view's @State, and .onDisappear tore it down along with the background assertion — so switching tabs, locking the phone, or ~30s of background each stopped the resends, let the simulation connection die, and snapped the device back to its real location. - Add LocationSimulationKeepAlive, which owns the held coordinate, resends it from a dispatch timer instead of the main runloop, takes forced audio and location holds (so it works with the user's keep-alive toggles off), and renews its background assertion rather than letting it expire. - MapSelectionView delegates the resend loop to the service, and .onDisappear no longer stops the simulation — only an explicit Clear releases it. On reappear the view re-adopts the held coordinate so the UI is not misleading. - MainTabView's URL-scheme path holds on simulate, and releases *before* clearing so a queued resend cannot re-apply a location just cleared. This is the part of the reliability problem the PR never addressed: its network-path work only governs the JIT/debug tunnel, while location simulation runs over its own separate rp_pairing connection. Not verified on device — the background-suspension behaviour needs testing on real hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
de3f34292f
commit
91c16dd126
3 changed files with 231 additions and 11 deletions
204
StikDebug/Services/LocationSimulationKeepAlive.swift
Normal file
204
StikDebug/Services/LocationSimulationKeepAlive.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Void, Never>?
|
||||
@State private var routeSpeedPrefetchTask: Task<Void, Never>?
|
||||
@State private var routePlaybackTask: Task<Void, Never>?
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue