diff --git a/heatguard-iOS/Core/Auth/HGAuthenticationService.swift b/heatguard-iOS/Core/Auth/HGAuthenticationService.swift index 0b9e120..28d4cba 100644 --- a/heatguard-iOS/Core/Auth/HGAuthenticationService.swift +++ b/heatguard-iOS/Core/Auth/HGAuthenticationService.swift @@ -25,6 +25,7 @@ struct HGAuthenticationService { method: "POST", path: HGAPIPath.teamLogin ) + HGProfilePhotoCache.shared.clear() try tokenStore.save(response.accessToken) // Passwords from the old test-account recovery path must not survive login. try credentialStore.clear() @@ -165,6 +166,7 @@ struct HGAuthenticationService { } func endLocalSession(clearDraft: Bool = true) { + HGProfilePhotoCache.shared.clear() if clearDraft { try? HGRecordDraftStore(ownerID: sessionState.userID).clear() } diff --git a/heatguard-iOS/Core/Auth/HGProfilePhotoService.swift b/heatguard-iOS/Core/Auth/HGProfilePhotoService.swift index b84863b..baa7040 100644 --- a/heatguard-iOS/Core/Auth/HGProfilePhotoService.swift +++ b/heatguard-iOS/Core/Auth/HGProfilePhotoService.swift @@ -11,11 +11,18 @@ struct HGProfilePhotoService { } func loadPhoto() async throws -> UIImage? { + if let data = HGProfilePhotoCache.shared.cachedData(), let image = UIImage(data: data) { + return image + } + let response: HGProfilePhotoResponse = try await client.get( path: HGAPIPath.teamProfilePhoto, requiresAuthentication: true ) - guard let rawURL = response.photoURL else { return nil } + guard let rawURL = response.photoURL else { + HGProfilePhotoCache.shared.clear() + return nil + } guard let url = URL(string: rawURL), url.scheme?.lowercased() == "https" else { throw HGProfilePhotoError.invalidURL } @@ -27,6 +34,7 @@ struct HGProfilePhotoService { let image = UIImage(data: data) else { throw HGProfilePhotoError.downloadFailed } + HGProfilePhotoCache.shared.store(data) return image } @@ -36,6 +44,7 @@ struct HGProfilePhotoService { try Task.checkCancellation() do { try await uploadPhotoData(data) + HGProfilePhotoCache.shared.store(data) return } catch HGProfilePhotoError.uploadExpired where attempt == 0 { continue @@ -114,6 +123,7 @@ struct HGProfilePhotoService { path: HGAPIPath.teamProfilePhoto, requiresAuthentication: true ) + HGProfilePhotoCache.shared.clear() } private static func jpegData(from image: UIImage) throws -> Data { @@ -143,6 +153,41 @@ struct HGProfilePhotoService { } } +final class HGProfilePhotoCache: @unchecked Sendable { + static let shared = HGProfilePhotoCache() + + private let lock = NSLock() + private var imageData: Data? + private var expirationDate: Date? + + private init() {} + + func cachedData() -> Data? { + lock.lock() + defer { lock.unlock() } + guard let expirationDate, expirationDate > .now else { + imageData = nil + self.expirationDate = nil + return nil + } + return imageData + } + + func store(_ data: Data) { + lock.lock() + defer { lock.unlock() } + imageData = data + expirationDate = .now.addingTimeInterval(10 * 60) + } + + func clear() { + lock.lock() + defer { lock.unlock() } + imageData = nil + expirationDate = nil + } +} + private struct HGProfilePhotoUploadRequest: Encodable { let contentType: String let size: Int diff --git a/heatguard-iOS/Core/Network/HGAPIClient.swift b/heatguard-iOS/Core/Network/HGAPIClient.swift index 7af9076..da88c3e 100644 --- a/heatguard-iOS/Core/Network/HGAPIClient.swift +++ b/heatguard-iOS/Core/Network/HGAPIClient.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog import Security enum HGAPIConfiguration { @@ -19,6 +20,11 @@ enum HGAPIConfiguration { } struct HGAPIClient { + private static let homeRequestLogger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "HeatGuard", + category: "HomeRequest" + ) + private let session: URLSession private let tokenStore: HGAuthTokenStore private let notificationCenter: NotificationCenter @@ -151,7 +157,41 @@ struct HGAPIClient { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, httpResponse) = try await authenticatedData(for: request, requiresAuthentication: requiresAuthentication) + let isHomeRequest = path == HGAPIPath.teamHome + let requestStartedAt = ProcessInfo.processInfo.systemUptime + let data: Data + let httpResponse: HTTPURLResponse + do { + (data, httpResponse) = try await authenticatedData(for: request, requiresAuthentication: requiresAuthentication) + } catch { + if isHomeRequest { + let elapsedMilliseconds = Int((ProcessInfo.processInfo.systemUptime - requestStartedAt) * 1_000) + let diagnostic = (error as? URLError).map { "URL_ERROR_\($0.code.rawValue)" } + ?? String(describing: type(of: error)) + Self.homeRequestLogger.error( + "Home request failed method=\(method, privacy: .public) path=\(path, privacy: .public)" + ) + Self.homeRequestLogger.error( + "diagnostic=\(diagnostic, privacy: .public) elapsedMs=\(elapsedMilliseconds)" + ) + } + throw error + } + + if isHomeRequest { + let requestID = Self.requestID(from: data) ?? httpResponse.value(forHTTPHeaderField: "X-Request-Id") ?? "unavailable" + let elapsedMilliseconds = Int((ProcessInfo.processInfo.systemUptime - requestStartedAt) * 1_000) + let serverCode = Self.serverErrorCode(from: data) ?? "none" + Self.homeRequestLogger.info( + "Home response method=\(method, privacy: .public) path=\(path, privacy: .public)" + ) + Self.homeRequestLogger.info( + "status=\(httpResponse.statusCode) serverCode=\(serverCode, privacy: .public)" + ) + Self.homeRequestLogger.info( + "requestId=\(requestID, privacy: .public) elapsedMs=\(elapsedMilliseconds)" + ) + } guard (200 ... 299).contains(httpResponse.statusCode) else { throw responseError(from: data, statusCode: httpResponse.statusCode) @@ -171,10 +211,26 @@ struct HGAPIClient { } catch let error as HGAPIError { throw error } catch { + if isHomeRequest { + Self.homeRequestLogger.error( + "Home response decode failed path=\(path, privacy: .public) status=\(httpResponse.statusCode)" + ) + Self.homeRequestLogger.error( + "requestId=\(Self.requestID(from: data) ?? "unavailable", privacy: .public)" + ) + } throw HGAPIError.responseDecoding } } + private static func requestID(from data: Data) -> String? { + (try? JSONDecoder().decode(HGAPIEnvelope.self, from: data).meta?.requestID) + } + + private static func serverErrorCode(from data: Data) -> String? { + (try? JSONDecoder().decode(HGAPIEnvelope.self, from: data).error?.code) + } + private func responseError(from data: Data, statusCode: Int) -> HGAPIError { let errorEnvelope = try? JSONDecoder().decode(HGAPIEnvelope.self, from: data) let fallbackMessage = HTTPURLResponse.localizedString(forStatusCode: statusCode) @@ -216,6 +272,15 @@ struct HGAPIEnvelope: Decodable { let success: Bool let data: Payload? let error: HGAPIErrorDetail? + let meta: HGAPIResponseMeta? +} + +struct HGAPIResponseMeta: Decodable { + let requestID: String? + + enum CodingKeys: String, CodingKey { + case requestID = "requestId" + } } struct HGAPIErrorDetail: Decodable { diff --git a/heatguard-iOS/Presentation/Features/Home/HGMenuDrawer.swift b/heatguard-iOS/Presentation/Features/Home/HGMenuDrawer.swift index d31e448..1edc859 100644 --- a/heatguard-iOS/Presentation/Features/Home/HGMenuDrawer.swift +++ b/heatguard-iOS/Presentation/Features/Home/HGMenuDrawer.swift @@ -75,14 +75,13 @@ struct HGMenuDrawer: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .ignoresSafeArea() .task(id: isPresented) { - guard isPresented else { - profileImage = nil - return - } + guard isPresented else { return } do { profileImage = try await HGProfilePhotoService().loadPhoto() + } catch is CancellationError { + return } catch { - profileImage = nil + // Keep any previously loaded image visible when a refresh fails. } } } diff --git a/heatguard-iOS/Presentation/Features/Home/HomeView.swift b/heatguard-iOS/Presentation/Features/Home/HomeView.swift index 310d98e..330be8b 100644 --- a/heatguard-iOS/Presentation/Features/Home/HomeView.swift +++ b/heatguard-iOS/Presentation/Features/Home/HomeView.swift @@ -825,32 +825,68 @@ struct HomeView: View { Binding(get: { storedDraftError != nil }, set: { if !$0 { storedDraftError = nil } }) } - private func loadDashboard() async { - do { - let loadedDashboard = try await HGDashboardService().fetchHomeDashboard() - dashboard = loadedDashboard - unreadNotificationCount = loadedDashboard.unreadNotificationCount - checklist = loadedDashboard.checklist - dashboardError = nil + @MainActor + private func loadProfile() async { + teamProfile = try? await HGAuthenticationService().currentProfile() + } +} +extension HomeView { + fileprivate func loadDashboard() async { + for attempt in 0 ... 1 { do { - let items = try await HGChecklistService().fetchItems() - checklist = HGChecklistSummary( - times: loadedDashboard.checklist.times, - checkedCount: items.filter(\.checked).count, - totalCount: items.count - ) - } catch { + let loadedDashboard = try await HGDashboardService().fetchHomeDashboard() + dashboard = loadedDashboard + unreadNotificationCount = loadedDashboard.unreadNotificationCount checklist = loadedDashboard.checklist + dashboardError = nil + + do { + let items = try await HGChecklistService().fetchItems() + checklist = HGChecklistSummary( + times: loadedDashboard.checklist.times, + checkedCount: items.filter(\.checked).count, + totalCount: items.count + ) + } catch { + checklist = loadedDashboard.checklist + } + return + } catch { + guard !error.hgIsNetworkCancellation else { return } + guard attempt == 0, HGDashboardRetryPolicy.shouldRetry(error) else { + dashboardError = HGErrorPresentation(error: error) + return + } + + do { + try await Task.sleep(nanoseconds: 350_000_000) + } catch { + return + } } - } catch { - dashboardError = HGErrorPresentation(error: error) } } +} - @MainActor - private func loadProfile() async { - teamProfile = try? await HGAuthenticationService().currentProfile() +private enum HGDashboardRetryPolicy { + static func shouldRetry(_ error: Error) -> Bool { + if let urlError = error as? URLError { + return [ + .timedOut, + .cannotFindHost, + .cannotConnectToHost, + .networkConnectionLost, + .dnsLookupFailed, + .notConnectedToInternet + ].contains(urlError.code) + } + + guard let apiError = error as? HGAPIError, + case let .server(_, statusCode, _) = apiError else { + return false + } + return statusCode == 408 || statusCode == 429 || (500 ... 599).contains(statusCode) } }