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:
umarfatmi05 2026-07-24 20:56:03 -04:00
parent de3f34292f
commit 91c16dd126
3 changed files with 231 additions and 11 deletions

View 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)
}
}
}

View file

@ -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 {

View file

@ -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
}