Improve concurrency model for iOS

This commit is contained in:
2026-01-08 19:18:44 -07:00
parent 1c47dd93b0
commit ec63d7c58f
15 changed files with 137 additions and 205 deletions

View File

@@ -484,7 +484,7 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -536,7 +536,7 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",

View File

@@ -41,7 +41,7 @@ final class SessionIntentController {
func startSessionWithLastUsedGym() async throws -> SessionIntentSummary {
// Wait for data to load
if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000)
try? await Task.sleep(for: .milliseconds(500))
}
guard let lastGym = dataManager.getLastUsedGym() else {
@@ -49,7 +49,7 @@ final class SessionIntentController {
throw SessionIntentError.noRecentGym
}
guard let startedSession = await dataManager.startSessionAsync(gymId: lastGym.id) else {
guard let startedSession = await dataManager.startSession(gymId: lastGym.id) else {
logFailure(.failedToStartSession, context: "Data manager failed to create new session")
throw SessionIntentError.failedToStartSession
}
@@ -68,7 +68,7 @@ final class SessionIntentController {
throw SessionIntentError.noActiveSession
}
guard let completedSession = await dataManager.endSessionAsync(activeSession.id) else {
guard let completedSession = await dataManager.endSession(activeSession.id) else {
logFailure(
.failedToEndSession, context: "Data manager failed to complete active session")
throw SessionIntentError.failedToEndSession
@@ -97,7 +97,7 @@ final class SessionIntentController {
func toggleSession() async throws -> (summary: SessionIntentSummary, wasStarted: Bool) {
// Wait for data to load
if dataManager.gyms.isEmpty {
try? await Task.sleep(nanoseconds: 500_000_000)
try? await Task.sleep(for: .milliseconds(500))
}
if dataManager.activeSession != nil {

View File

@@ -20,14 +20,14 @@ struct ToggleSessionIntent: AppIntent {
func perform() async throws -> some IntentResult & ProvidesDialog {
// Wait for app initialization
try? await Task.sleep(nanoseconds: 1_000_000_000)
try? await Task.sleep(for: .seconds(1))
let controller = await SessionIntentController()
let (summary, wasStarted) = try await controller.toggleSession()
if wasStarted {
// Wait for Live Activity
try? await Task.sleep(nanoseconds: 500_000_000)
try? await Task.sleep(for: .milliseconds(500))
return .result(dialog: IntentDialog("Session started at \(summary.gymName). Have an awesome climb!"))
} else {
return .result(dialog: IntentDialog("Session at \(summary.gymName) ended. Nice work!"))

View File

@@ -49,7 +49,7 @@ struct ContentView: View {
if newPhase == .active {
// Add slight delay to ensure app is fully loaded
Task {
try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds
try? await Task.sleep(for: .milliseconds(200))
dataManager.onAppBecomeActive()
// Re-verify health integration when app becomes active
await dataManager.healthKitService.verifyAndRestoreIntegration()
@@ -96,7 +96,7 @@ struct ContentView: View {
AppLogger.info(
"App will enter foreground - preparing Live Activity check", tag: "Lifecycle")
// Small delay to ensure app is fully active
try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 seconds
try? await Task.sleep(for: .milliseconds(800))
dataManager.onAppBecomeActive()
// Re-verify health integration when returning from background
await dataManager.healthKitService.verifyAndRestoreIntegration()
@@ -112,7 +112,7 @@ struct ContentView: View {
Task { @MainActor in
AppLogger.info(
"App did become active - checking Live Activity status", tag: "Lifecycle")
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
try? await Task.sleep(for: .milliseconds(300))
dataManager.onAppBecomeActive()
await dataManager.healthKitService.verifyAndRestoreIntegration()
}

View File

@@ -68,21 +68,16 @@ class MusicService: ObservableObject {
}
private func updatePlaybackStatus() {
Task { @MainActor [weak self] in
self?.isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing
}
isPlaying = SystemMusicPlayer.shared.state.playbackStatus == .playing
}
private func checkQueueConsistency() {
guard hasStartedSessionPlayback else { return }
Task { @MainActor [weak self] in
guard let self = self else { return }
if let currentEntry = SystemMusicPlayer.shared.queue.currentEntry,
let item = currentEntry.item {
if !self.currentPlaylistTrackIds.isEmpty && !self.currentPlaylistTrackIds.contains(item.id) {
self.hasStartedSessionPlayback = false
}
if !currentPlaylistTrackIds.isEmpty && !currentPlaylistTrackIds.contains(item.id) {
hasStartedSessionPlayback = false
}
}
}

View File

@@ -23,8 +23,6 @@ class SyncService: ObservableObject {
private let userDefaults = UserDefaults.standard
private let logTag = "SyncService"
private var syncTask: Task<Void, Never>?
private var pendingChanges = false
private let syncDebounceDelay: TimeInterval = 2.0
private enum Keys {
static let serverURL = "sync_server_url"
@@ -162,34 +160,19 @@ class SyncService: ObservableObject {
return
}
if isSyncing {
pendingChanges = true
return
}
guard !isSyncing else { return }
syncTask?.cancel()
syncTask = Task {
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
repeat {
pendingChanges = false
do {
try await syncWithServer(dataManager: dataManager)
} catch {
await MainActor.run {
self.isSyncing = false
}
return
}
if pendingChanges {
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
}
} while pendingChanges && !Task.isCancelled
}
}
@@ -198,25 +181,21 @@ class SyncService: ObservableObject {
syncTask?.cancel()
syncTask = nil
pendingChanges = false
Task {
do {
try await syncWithServer(dataManager: dataManager)
} catch {
await MainActor.run {
self.isSyncing = false
}
}
}
}
func disconnect() {
activeProvider?.disconnect()
syncTask?.cancel()
syncTask = nil
pendingChanges = false
isSyncing = false
isConnected = false
lastSyncTime = nil
@@ -239,7 +218,6 @@ class SyncService: ObservableObject {
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
syncTask?.cancel()
syncTask = nil
pendingChanges = false
activeProvider?.disconnect()
}

View File

@@ -93,7 +93,7 @@ class ClimbingDataManager: ObservableObject {
.assign(to: &$isSyncing)
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
try? await Task.sleep(for: .seconds(2))
await performImageMaintenance()
// Check if we need to restart Live Activity for active session
@@ -417,17 +417,10 @@ class ClimbingDataManager: ObservableObject {
return problems.filter { $0.gymId == gymId && $0.isActive }
}
func startSession(gymId: UUID, notes: String? = nil) {
Task { @MainActor in
await startSessionAsync(gymId: gymId, notes: notes)
}
}
@discardableResult
func startSessionAsync(gymId: UUID, notes: String? = nil) async -> ClimbSession? {
// End any currently active session before starting a new one
func startSession(gymId: UUID, notes: String? = nil) async -> ClimbSession? {
if let currentActive = activeSession {
await endSessionAsync(currentActive.id)
await endSession(currentActive.id)
}
let newSession = ClimbSession(gymId: gymId, notes: notes)
@@ -462,14 +455,8 @@ class ClimbingDataManager: ObservableObject {
return newSession
}
func endSession(_ sessionId: UUID) {
Task { @MainActor in
await endSessionAsync(sessionId)
}
}
@discardableResult
func endSessionAsync(_ sessionId: UUID) async -> ClimbSession? {
func endSession(_ sessionId: UUID) async -> ClimbSession? {
guard
let session = sessions.first(where: { $0.id == sessionId && $0.status == .active }),
let index = sessions.firstIndex(where: { $0.id == sessionId })
@@ -999,7 +986,7 @@ class ClimbingDataManager: ObservableObject {
private func clearMessageAfterDelay() {
Task {
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
try? await Task.sleep(for: .seconds(3))
successMessage = nil
errorMessage = nil
}
@@ -1247,30 +1234,25 @@ extension ClimbingDataManager {
func testLiveActivity() {
AppLogger.info("Testing Live Activity functionality...", tag: LogTag.climbingData)
// Check Live Activity availability
let status = LiveActivityManager.shared.checkLiveActivityAvailability()
AppLogger.info(status, tag: LogTag.climbingData)
// Test with dummy data if we have a gym
guard let testGym = gyms.first else {
AppLogger.error("No gyms available for testing", tag: LogTag.climbingData)
return
}
// Create a test session
let testSession = ClimbSession(gymId: testGym.id, notes: "Test session for Live Activity")
Task {
await LiveActivityManager.shared.startLiveActivity(
for: testSession, gymName: testGym.name)
// Wait a bit then update
try? await Task.sleep(nanoseconds: 2_000_000_000)
try? await Task.sleep(for: .seconds(2))
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 120, totalAttempts: 5, completedProblems: 1)
// Wait then end
try? await Task.sleep(nanoseconds: 5_000_000_000)
try? await Task.sleep(for: .seconds(5))
await LiveActivityManager.shared.endLiveActivity()
}
}
@@ -1379,8 +1361,7 @@ extension ClimbingDataManager {
"Attempting to restart dismissed Live Activity for \(gym.name)",
tag: LogTag.climbingData)
// Wait a bit before restarting to avoid frequency limits
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
try? await Task.sleep(for: .seconds(2))
await LiveActivityManager.shared.startLiveActivity(
for: activeSession,

View File

@@ -5,15 +5,16 @@ extension Notification.Name {
static let liveActivityDismissed = Notification.Name("liveActivityDismissed")
}
extension Activity: @unchecked @retroactive Sendable where Attributes: Sendable {}
@MainActor
final class LiveActivityManager {
static let shared = LiveActivityManager()
private static let logTag = "LiveActivity"
nonisolated private static let logTag = "LiveActivity"
private init() {}
nonisolated(unsafe) private var currentActivity: Activity<SessionActivityAttributes>?
private var healthCheckTimer: Timer?
private var lastHealthCheck: Date = Date()
private var healthCheckTask: Task<Void, Never>?
/// Check if there's an active session and restart Live Activity if needed
func restartLiveActivityIfNeeded(activeSession: ClimbSession?, gymName: String?) async {
@@ -106,21 +107,19 @@ final class LiveActivityManager {
}
}
/// Call this to update the Live Activity with new session progress
func updateLiveActivity(elapsed: TimeInterval, totalAttempts: Int, completedProblems: Int) async
{
guard let currentActivity = currentActivity else {
guard let activity = currentActivity else {
AppLogger.warning("No current activity to update", tag: Self.logTag)
return
}
// Verify the activity is still valid before updating
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
let isStillActive = activities.contains { $0.id == activity.id }
if !isStillActive {
AppLogger.warning(
"Tracked Live Activity \(currentActivity.id) is no longer active, clearing reference",
"Tracked Live Activity \(activity.id) is no longer active, clearing reference",
tag: Self.logTag
)
self.currentActivity = nil
@@ -138,25 +137,19 @@ final class LiveActivityManager {
completedProblems: completedProblems
)
nonisolated(unsafe) let activity = currentActivity
await activity.update(.init(state: updatedContentState, staleDate: nil))
}
/// Call this when a ClimbSession ends to end the Live Activity
func endLiveActivity() async {
// Stop health checks first
stopHealthChecks()
// First end the tracked activity if it exists
if let currentActivity {
AppLogger.info("Ending tracked Live Activity: \(currentActivity.id)", tag: Self.logTag)
nonisolated(unsafe) let activity = currentActivity
if let activity = currentActivity {
AppLogger.info("Ending tracked Live Activity: \(activity.id)", tag: Self.logTag)
await activity.end(nil, dismissalPolicy: .immediate)
self.currentActivity = nil
AppLogger.info("Tracked Live Activity ended successfully", tag: Self.logTag)
}
// Force end ALL active activities of our type to ensure cleanup
AppLogger.debug("Checking for any remaining active activities...", tag: Self.logTag)
let activities = Activity<SessionActivityAttributes>.activities
@@ -203,38 +196,29 @@ final class LiveActivityManager {
}
}
/// Start periodic health checks for Live Activity
func startHealthChecks() {
stopHealthChecks() // Stop any existing timer
stopHealthChecks()
AppLogger.debug("Starting Live Activity health checks", tag: Self.logTag)
healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) {
[weak self] _ in
Task { @MainActor [weak self] in
await self?.performHealthCheck()
healthCheckTask = Task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(30))
guard !Task.isCancelled else { break }
await performHealthCheck()
}
}
}
/// Stop periodic health checks
func stopHealthChecks() {
healthCheckTimer?.invalidate()
healthCheckTimer = nil
healthCheckTask?.cancel()
healthCheckTask = nil
AppLogger.debug("Stopped Live Activity health checks", tag: Self.logTag)
}
/// Perform a health check on the current Live Activity
private func performHealthCheck() async {
guard let currentActivity = currentActivity else { return }
let now = Date()
let timeSinceLastCheck = now.timeIntervalSince(lastHealthCheck)
// Only perform health check if it's been at least 25 seconds
guard timeSinceLastCheck >= 25 else { return }
AppLogger.debug("Performing Live Activity health check", tag: Self.logTag)
lastHealthCheck = now
let activities = Activity<SessionActivityAttributes>.activities
let isStillActive = activities.contains { $0.id == currentActivity.id }
@@ -242,18 +226,12 @@ final class LiveActivityManager {
if !isStillActive {
AppLogger.warning("Health check failed - Live Activity was dismissed", tag: Self.logTag)
self.currentActivity = nil
// Notify that we need to restart
NotificationCenter.default.post(
name: .liveActivityDismissed,
object: nil
)
NotificationCenter.default.post(name: .liveActivityDismissed, object: nil)
} else {
AppLogger.debug("Live Activity health check passed", tag: Self.logTag)
}
}
/// Get the current activity status for debugging
func getCurrentActivityStatus() -> String {
let activities = Activity<SessionActivityAttributes>.activities
let trackedStatus = currentActivity != nil ? "Tracked" : "None"
@@ -262,12 +240,11 @@ final class LiveActivityManager {
return "Status: \(trackedStatus) | Active Count: \(actualCount)"
}
/// Start periodic updates for Live Activity
func startPeriodicUpdates(for session: ClimbSession, totalAttempts: Int, completedProblems: Int)
{
guard currentActivity != nil else { return }
Task { @MainActor in
Task {
while currentActivity != nil {
let elapsed = Date().timeIntervalSince(session.startTime ?? session.date)
await updateLiveActivity(
@@ -275,9 +252,7 @@ final class LiveActivityManager {
totalAttempts: totalAttempts,
completedProblems: completedProblems
)
// Wait 30 seconds before next update
try? await Task.sleep(nanoseconds: 30_000_000_000)
try? await Task.sleep(for: .seconds(30))
}
}
}

View File

@@ -123,11 +123,13 @@ struct AddEditSessionView: View {
if isEditing, let session = existingSession {
let updatedSession = session.updated(notes: notes.isEmpty ? nil : notes)
dataManager.updateSession(updatedSession)
} else {
dataManager.startSession(gymId: gym.id, notes: notes.isEmpty ? nil : notes)
}
dismiss()
} else {
Task {
await dataManager.startSession(gymId: gym.id, notes: notes.isEmpty ? nil : notes)
dismiss()
}
}
}
}

View File

@@ -138,9 +138,11 @@ struct SessionDetailView: View {
if let session = session {
if session.status == .active {
Button("End Session") {
dataManager.endSession(session.id)
Task {
await dataManager.endSession(session.id)
dismiss()
}
}
.foregroundColor(.orange)
} else {
Button {

View File

@@ -220,7 +220,7 @@ struct LiveActivityDebugView: View {
appendDebugOutput("Live Activity start request sent")
// Wait and update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
try? await Task.sleep(for: .seconds(3))
appendDebugOutput("Updating Live Activity with test data...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 180,
@@ -229,7 +229,7 @@ struct LiveActivityDebugView: View {
)
// Another update
try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
try? await Task.sleep(for: .seconds(3))
appendDebugOutput("Second update...")
await LiveActivityManager.shared.updateLiveActivity(
elapsed: 360,
@@ -238,7 +238,7 @@ struct LiveActivityDebugView: View {
)
// End after delay
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds
try? await Task.sleep(for: .seconds(5))
appendDebugOutput("Ending Live Activity...")
await LiveActivityManager.shared.endLiveActivity()
@@ -253,9 +253,11 @@ struct LiveActivityDebugView: View {
}
appendDebugOutput("Ending current session: \(activeSession.id)")
dataManager.endSession(activeSession.id)
Task {
await dataManager.endSession(activeSession.id)
appendDebugOutput("Session ended")
}
}
private func forceLiveActivityUpdate() {
appendDebugOutput("Forcing Live Activity update...")

View File

@@ -19,12 +19,8 @@ struct ProblemsView: View {
@State private var animationKey = 0
private func updateFilteredProblems() {
Task(priority: .userInitiated) {
let result = await computeFilteredProblems()
// Switch back to the main thread to update the UI
await MainActor.run {
cachedFilteredProblems = result
}
Task {
cachedFilteredProblems = await computeFilteredProblems()
}
}

View File

@@ -80,7 +80,9 @@ struct SessionsView: View {
} else if dataManager.activeSession == nil {
Button("Start Session") {
if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id)
Task {
await dataManager.startSession(gymId: dataManager.gyms.first!.id)
}
} else {
showingAddSession = true
}
@@ -228,7 +230,9 @@ struct ActiveSessionBanner: View {
}
Button(action: {
dataManager.endSession(session.id)
Task {
await dataManager.endSession(session.id)
}
}) {
Image(systemName: "stop.fill")
.font(.system(size: 16, weight: .bold))
@@ -327,7 +331,9 @@ struct EmptySessionsView: View {
if !dataManager.gyms.isEmpty {
Button("Start Session") {
if dataManager.gyms.count == 1 {
dataManager.startSession(gymId: dataManager.gyms.first!.id)
Task {
await dataManager.startSession(gymId: dataManager.gyms.first!.id)
}
} else {
showingAddSession = true
}

View File

@@ -474,8 +474,8 @@ struct ExportDataView: View {
}
private func createTempFile() {
let logTag = Self.logTag // Capture before entering background queue
DispatchQueue.global(qos: .userInitiated).async {
let logTag = Self.logTag
Task.detached(priority: .userInitiated) {
do {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
@@ -489,28 +489,23 @@ struct ExportDataView: View {
for: .documentDirectory, in: .userDomainMask
).first
else {
Task { @MainActor in
await MainActor.run {
AppLogger.error("Could not access Documents directory", tag: logTag)
}
DispatchQueue.main.async {
self.isCreatingFile = false
}
return
}
let fileURL = documentsURL.appendingPathComponent(filename)
// Write the ZIP data to the file
try data.write(to: fileURL)
DispatchQueue.main.async {
await MainActor.run {
self.tempFileURL = fileURL
self.isCreatingFile = false
}
} catch {
Task { @MainActor in
await MainActor.run {
AppLogger.error("Failed to create export file: \(error)", tag: logTag)
}
DispatchQueue.main.async {
self.isCreatingFile = false
}
}