mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +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 SwiftUI
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
private enum ExternalLocationAction: Identifiable {
|
private enum ExternalLocationAction: Identifiable {
|
||||||
case simulate(URL, Double, Double)
|
case simulate(URL, Double, Double)
|
||||||
|
|
@ -223,6 +224,14 @@ struct MainTabView: View {
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if code == 0 {
|
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()
|
BackgroundLocationManager.shared.requestStart()
|
||||||
LogManager.shared.addInfoLog(
|
LogManager.shared.addInfoLog(
|
||||||
String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude)
|
String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude)
|
||||||
|
|
@ -239,6 +248,9 @@ struct MainTabView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearSimulatedLocation() {
|
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 {
|
LocationSimulationCommandQueue.shared.async {
|
||||||
let code = clear_simulated_location()
|
let code = clear_simulated_location()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
|
|
||||||
|
|
@ -728,7 +728,6 @@ struct LocationSimulationView: View {
|
||||||
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
|
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
|
||||||
|
|
||||||
@State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
@State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
||||||
@State private var resendTimer: Timer?
|
|
||||||
@State private var routeLoadTask: Task<Void, Never>?
|
@State private var routeLoadTask: Task<Void, Never>?
|
||||||
@State private var routeSpeedPrefetchTask: Task<Void, Never>?
|
@State private var routeSpeedPrefetchTask: Task<Void, Never>?
|
||||||
@State private var routePlaybackTask: Task<Void, Never>?
|
@State private var routePlaybackTask: Task<Void, Never>?
|
||||||
|
|
@ -1025,6 +1024,14 @@ struct LocationSimulationView: View {
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
loadBookmarks()
|
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 {
|
.onDisappear {
|
||||||
routeLoadTask?.cancel()
|
routeLoadTask?.cancel()
|
||||||
|
|
@ -1032,7 +1039,9 @@ struct LocationSimulationView: View {
|
||||||
routeSpeedPrefetchTask?.cancel()
|
routeSpeedPrefetchTask?.cancel()
|
||||||
routeSpeedPrefetchTask = nil
|
routeSpeedPrefetchTask = nil
|
||||||
cancelRoutePlayback(resetMarker: true)
|
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 {
|
if backgroundTaskID != .invalid {
|
||||||
BackgroundLocationManager.shared.requestStop()
|
BackgroundLocationManager.shared.requestStop()
|
||||||
}
|
}
|
||||||
|
|
@ -1379,18 +1388,13 @@ struct LocationSimulationView: View {
|
||||||
|
|
||||||
private func startResendLoop(with coordinate: CLLocationCoordinate2D) {
|
private func startResendLoop(with coordinate: CLLocationCoordinate2D) {
|
||||||
simulatedCoordinate = coordinate
|
simulatedCoordinate = coordinate
|
||||||
resendTimer?.invalidate()
|
// Owned by the service, not this view: a main-runloop Timer stops when
|
||||||
resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in
|
// the app is suspended, and @State dies with the screen.
|
||||||
guard let simulatedCoordinate else { return }
|
LocationSimulationKeepAlive.shared.hold(coordinate)
|
||||||
LocationSimulationCommandQueue.shared.async {
|
|
||||||
_ = locationUpdateCode(for: simulatedCoordinate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopResendLoop() {
|
private func stopResendLoop() {
|
||||||
resendTimer?.invalidate()
|
LocationSimulationKeepAlive.shared.release()
|
||||||
resendTimer = nil
|
|
||||||
simulatedCoordinate = nil
|
simulatedCoordinate = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue