fix: preserve background activity sessions

This commit is contained in:
CelloSerenity 2026-09-27 14:52:35 -06:00
parent 6a078be17e
commit e7ef9491e2
7 changed files with 198 additions and 78 deletions

View file

@ -11,7 +11,7 @@ enum AppBootstrapper {
static func configure() { static func configure() {
IdeviceLogFileManager.shared.prepareForLogging() IdeviceLogFileManager.shared.prepareForLogging()
registerDefaultSettings() registerDefaultSettings()
startConfiguredKeepAliveServices() ensureKeepAliveSelection()
applyDocumentPickerCopyWorkaround() applyDocumentPickerCopyWorkaround()
} }
@ -28,15 +28,10 @@ enum AppBootstrapper {
]) ])
} }
private static func startConfiguredKeepAliveServices() { private static func ensureKeepAliveSelection() {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
if defaults.bool(forKey: "keepAliveAudio") { if !defaults.bool(forKey: "keepAliveAudio"), !defaults.bool(forKey: "keepAliveLocation") {
BackgroundAudioManager.shared.start()
} else if defaults.bool(forKey: "keepAliveLocation") {
BackgroundLocationManager.shared.start()
} else {
defaults.set(true, forKey: "keepAliveAudio") defaults.set(true, forKey: "keepAliveAudio")
BackgroundAudioManager.shared.start()
} }
} }

View file

@ -11,7 +11,6 @@ final class BackgroundAudioManager {
private var engine = AVAudioEngine() private var engine = AVAudioEngine()
private var player = AVAudioPlayerNode() private var player = AVAudioPlayerNode()
private var isRunning = false private var isRunning = false
private var persistentEnabled = false
private var activityCount = 0 private var activityCount = 0
private var healthCheckTimer: Timer? private var healthCheckTimer: Timer?
@ -30,13 +29,7 @@ final class BackgroundAudioManager {
) )
} }
func start() { func configurationDidChange() {
persistentEnabled = true
refreshRunningState()
}
func stop() {
persistentEnabled = false
refreshRunningState() refreshRunningState()
} }
@ -51,7 +44,7 @@ final class BackgroundAudioManager {
} }
private func refreshRunningState() { 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 { guard shouldRun != isRunning else {
if shouldRun { if shouldRun {
recoverIfNeeded() recoverIfNeeded()

View file

@ -4,14 +4,15 @@
// //
import CoreLocation import CoreLocation
import UIKit
final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate { final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
static let shared = BackgroundLocationManager() static let shared = BackgroundLocationManager()
private let locationManager = CLLocationManager() private let locationManager = CLLocationManager()
private var isRunning = false private var isRunning = false
private var persistentEnabled = false
private var activityCount = 0 private var activityCount = 0
private var isRequestingAuthorization = false
private override init() { private override init() {
super.init() super.init()
@ -22,13 +23,7 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
locationManager.pausesLocationUpdatesAutomatically = false locationManager.pausesLocationUpdatesAutomatically = false
} }
func start() { func configurationDidChange() {
persistentEnabled = true
refreshRunningState()
}
func stop() {
persistentEnabled = false
refreshRunningState() refreshRunningState()
} }
@ -42,8 +37,22 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
refreshRunningState() 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() { 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 } guard shouldRun != isRunning else { return }
isRunning = shouldRun isRunning = shouldRun
@ -67,24 +76,47 @@ final class BackgroundLocationManager: NSObject, CLLocationManagerDelegate {
} }
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard isRunning else { return }
switch manager.authorizationStatus { switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorizedWhenInUse:
isRequestingAuthorization = false
if isRunning {
manager.startUpdatingLocation() manager.startUpdatingLocation()
}
case .denied, .restricted: case .denied, .restricted:
if isRunning || isRequestingAuthorization {
isRequestingAuthorization = false
restoreAudioFallback() restoreAudioFallback()
}
case .notDetermined: case .notDetermined:
return return
@unknown default: @unknown default:
if isRunning || isRequestingAuthorization {
isRequestingAuthorization = false
restoreAudioFallback() restoreAudioFallback()
} }
} }
}
private func restoreAudioFallback() { private func restoreAudioFallback() {
persistentEnabled = false let defaults = UserDefaults.standard
isRunning = activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveLocation") defaults.set(false, forKey: "keepAliveLocation")
UserDefaults.standard.set(true, forKey: "keepAliveAudio") defaults.set(true, forKey: "keepAliveAudio")
BackgroundAudioManager.shared.start() 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) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {

View file

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

View file

@ -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,7 +224,22 @@ struct MainTabView: View {
DispatchQueue.main.async { DispatchQueue.main.async {
if code == 0 { 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( 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)
) )
@ -243,7 +259,7 @@ struct MainTabView: View {
let code = clear_simulated_location() let code = clear_simulated_location()
DispatchQueue.main.async { DispatchQueue.main.async {
if code == 0 { if code == 0 {
BackgroundLocationManager.shared.requestStop() LocationSimulationSession.shared.stop()
LogManager.shared.addInfoLog("Cleared simulated location from URL") LogManager.shared.addInfoLog("Cleared simulated location from URL")
} else { } else {
showAlert( showAlert(

View file

@ -724,11 +724,11 @@ final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCo
} }
struct LocationSimulationView: View { struct LocationSimulationView: View {
@ObservedObject private var simulationSession = LocationSimulationSession.shared
@State private var coordinate: CLLocationCoordinate2D? @State private var coordinate: CLLocationCoordinate2D?
@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>?
@ -788,11 +788,11 @@ struct LocationSimulationView: View {
} }
private var hasActiveSimulation: Bool { private var hasActiveSimulation: Bool {
simulatedCoordinate != nil || routePlaybackTask != nil simulationSession.isActive || simulatedCoordinate != nil || routePlaybackTask != nil
} }
private var isRouteRunning: Bool { private var isRouteRunning: Bool {
routePlaybackTask != nil routePlaybackTask != nil || simulationSession.routeCoordinates != nil
} }
private var hasRouteContext: Bool { private var hasRouteContext: Bool {
@ -801,7 +801,12 @@ struct LocationSimulationView: View {
routePlan != nil || routePlan != nil ||
isLoadingRoute || isLoadingRoute ||
isPrefetchingRouteSpeeds || isPrefetchingRouteSpeeds ||
routePlaybackCoordinate != nil routePlaybackCoordinate != nil ||
simulationSession.routeCoordinates != nil
}
private var displayedRouteCoordinate: CLLocationCoordinate2D? {
simulationSession.routeCoordinates == nil ? routePlaybackCoordinate : simulationSession.coordinate
} }
private var routeSummaryText: String? { private var routeSummaryText: String? {
@ -818,6 +823,9 @@ struct LocationSimulationView: View {
} }
private var routeStatusText: String { private var routeStatusText: String {
if simulationSession.routeCoordinates != nil {
return "Route simulation active."
}
if isLoadingRoute { if isLoadingRoute {
return "Calculating route…" return "Calculating route…"
} }
@ -892,8 +900,8 @@ struct LocationSimulationView: View {
Marker("End", coordinate: routeEndCoordinate) Marker("End", coordinate: routeEndCoordinate)
.tint(.red) .tint(.red)
} }
if let routePlaybackCoordinate { if let displayedRouteCoordinate {
Marker("Current", coordinate: routePlaybackCoordinate) Marker("Current", coordinate: displayedRouteCoordinate)
.tint(.blue) .tint(.blue)
} }
} else if let coordinate { } else if let coordinate {
@ -1025,17 +1033,28 @@ struct LocationSimulationView: View {
} }
.onAppear { .onAppear {
loadBookmarks() 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 { .onDisappear {
routeLoadTask?.cancel() routeLoadTask?.cancel()
routeLoadTask = nil routeLoadTask = nil
routeSpeedPrefetchTask?.cancel() routeSpeedPrefetchTask?.cancel()
routeSpeedPrefetchTask = nil routeSpeedPrefetchTask = nil
cancelRoutePlayback(resetMarker: true)
stopResendLoop()
if backgroundTaskID != .invalid {
BackgroundLocationManager.shared.requestStop()
}
endBackgroundTask() endBackgroundTask()
} }
} }
@ -1297,14 +1316,14 @@ struct LocationSimulationView: View {
) { ) {
routePlaybackCoordinate = nil routePlaybackCoordinate = nil
beginBackgroundTask() beginBackgroundTask()
LocationSimulationSession.shared.clearRoute()
startResendLoop(with: coord) startResendLoop(with: coord)
BackgroundLocationManager.shared.requestStart()
} }
} }
private func simulateRoute() { private func simulateRoute() {
guard pairingExists, guard pairingExists,
routePlan != nil, let routePlan,
let firstCoordinate = routePlaybackSamples.first?.coordinate, let firstCoordinate = routePlaybackSamples.first?.coordinate,
!isBusy else { !isBusy else {
return return
@ -1319,10 +1338,13 @@ struct LocationSimulationView: View {
operation: { locationUpdateCode(for: firstCoordinate) } operation: { locationUpdateCode(for: firstCoordinate) }
) { ) {
beginBackgroundTask() beginBackgroundTask()
BackgroundLocationManager.shared.requestStart() let routeID = LocationSimulationSession.shared.startRoute(
at: firstCoordinate,
coordinates: routePlan.displayCoordinates
)
simulatedCoordinate = nil simulatedCoordinate = nil
routePlaybackCoordinate = firstCoordinate routePlaybackCoordinate = firstCoordinate
startRoutePlayback() startRoutePlayback(routeID: routeID)
} }
} }
@ -1356,13 +1378,14 @@ struct LocationSimulationView: View {
routeSpeedPrefetchTask = nil routeSpeedPrefetchTask = nil
cancelRoutePlayback(resetMarker: true) cancelRoutePlayback(resetMarker: true)
stopResendLoop() stopResendLoop()
LocationSimulationSession.shared.clearRoute()
runLocationCommand( runLocationCommand(
errorTitle: "Clear Failed", errorTitle: "Clear Failed",
errorMessage: { code in "Could not clear simulated location (error \(code))." }, errorMessage: { code in "Could not clear simulated location (error \(code))." },
operation: clear_simulated_location operation: clear_simulated_location
) { ) {
endBackgroundTask() endBackgroundTask()
BackgroundLocationManager.shared.requestStop() LocationSimulationSession.shared.stop()
} }
} }
@ -1379,18 +1402,15 @@ struct LocationSimulationView: View {
private func startResendLoop(with coordinate: CLLocationCoordinate2D) { private func startResendLoop(with coordinate: CLLocationCoordinate2D) {
simulatedCoordinate = coordinate simulatedCoordinate = coordinate
resendTimer?.invalidate() LocationSimulationSession.shared.startResending(at: coordinate) {
resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in
guard let simulatedCoordinate else { return }
LocationSimulationCommandQueue.shared.async { LocationSimulationCommandQueue.shared.async {
_ = locationUpdateCode(for: simulatedCoordinate) _ = locationUpdateCode(for: coordinate)
} }
} }
} }
private func stopResendLoop() { private func stopResendLoop() {
resendTimer?.invalidate() LocationSimulationSession.shared.pauseResending()
resendTimer = nil
simulatedCoordinate = nil simulatedCoordinate = nil
} }
@ -1520,15 +1540,19 @@ struct LocationSimulationView: View {
} }
} }
private func startRoutePlayback() { private func startRoutePlayback(routeID: UUID) {
routePlaybackTask = Task { routePlaybackTask = Task {
var lastSuccessfulCoordinate = routePlaybackSamples.first?.coordinate var lastSuccessfulCoordinate = routePlaybackSamples.first?.coordinate
for sample in routePlaybackSamples.dropFirst() { for sample in routePlaybackSamples.dropFirst() {
try? await Task.sleep(for: .seconds(sample.delayFromPrevious)) 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) let code = await sendLocationUpdate(for: sample.coordinate)
guard LocationSimulationSession.shared.isCurrentRoute(routeID) else { return }
guard code == 0 else { guard code == 0 else {
await MainActor.run { await MainActor.run {
routePlaybackTask = nil routePlaybackTask = nil
@ -1546,10 +1570,12 @@ struct LocationSimulationView: View {
lastSuccessfulCoordinate = sample.coordinate lastSuccessfulCoordinate = sample.coordinate
await MainActor.run { await MainActor.run {
routePlaybackCoordinate = sample.coordinate routePlaybackCoordinate = sample.coordinate
LocationSimulationSession.shared.updateCoordinate(sample.coordinate)
} }
} }
await MainActor.run { await MainActor.run {
guard LocationSimulationSession.shared.isCurrentRoute(routeID) else { return }
routePlaybackTask = nil routePlaybackTask = nil
if let lastSuccessfulCoordinate { if let lastSuccessfulCoordinate {
routePlaybackCoordinate = lastSuccessfulCoordinate routePlaybackCoordinate = lastSuccessfulCoordinate

View file

@ -242,32 +242,22 @@ struct SettingsView: View {
} }
private func handleAudioKeepAliveChange(_ enabled: Bool) { private func handleAudioKeepAliveChange(_ enabled: Bool) {
if enabled { if !enabled, !keepAliveLocation {
BackgroundAudioManager.shared.start()
BackgroundLocationManager.shared.stop()
return
}
if !keepAliveLocation {
keepAliveLocation = true keepAliveLocation = true
BackgroundLocationManager.shared.requestAuthorizationIfNeeded()
} }
BackgroundAudioManager.shared.stop() BackgroundAudioManager.shared.configurationDidChange()
BackgroundLocationManager.shared.start() BackgroundLocationManager.shared.configurationDidChange()
} }
private func handleLocationKeepAliveChange(_ enabled: Bool) { private func handleLocationKeepAliveChange(_ enabled: Bool) {
if enabled { if enabled {
if !keepAliveAudio { BackgroundLocationManager.shared.requestAuthorizationIfNeeded()
BackgroundLocationManager.shared.start() } else if !keepAliveAudio {
}
return
}
BackgroundLocationManager.shared.stop()
if !keepAliveAudio {
keepAliveAudio = true keepAliveAudio = true
BackgroundAudioManager.shared.start()
} }
BackgroundAudioManager.shared.configurationDidChange()
BackgroundLocationManager.shared.configurationDidChange()
} }
// MARK: - Business Logic // MARK: - Business Logic