mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
fix: preserve background activity sessions
This commit is contained in:
parent
6a078be17e
commit
e7ef9491e2
7 changed files with 198 additions and 78 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
manager.startUpdatingLocation()
|
||||
isRequestingAuthorization = false
|
||||
if isRunning {
|
||||
manager.startUpdatingLocation()
|
||||
}
|
||||
case .denied, .restricted:
|
||||
restoreAudioFallback()
|
||||
if isRunning || isRequestingAuthorization {
|
||||
isRequestingAuthorization = false
|
||||
restoreAudioFallback()
|
||||
}
|
||||
case .notDetermined:
|
||||
return
|
||||
@unknown default:
|
||||
restoreAudioFallback()
|
||||
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) {
|
||||
|
|
|
|||
68
StikDebug/Services/LocationSimulationSession.swift
Normal file
68
StikDebug/Services/LocationSimulationSession.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue