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

View file

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

View file

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

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 Foundation
import CoreLocation
private enum ExternalLocationAction: Identifiable {
case simulate(URL, Double, Double)
@ -223,7 +224,22 @@ struct MainTabView: View {
DispatchQueue.main.async {
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(
String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude)
)
@ -243,7 +259,7 @@ struct MainTabView: View {
let code = clear_simulated_location()
DispatchQueue.main.async {
if code == 0 {
BackgroundLocationManager.shared.requestStop()
LocationSimulationSession.shared.stop()
LogManager.shared.addInfoLog("Cleared simulated location from URL")
} else {
showAlert(

View file

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

View file

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