From c05c166b2ae841260158172e70449a4d303ab8e5 Mon Sep 17 00:00:00 2001 From: Book-on <266720604+dltldn1234@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:13:36 +0900 Subject: [PATCH 1/2] fix: [#398] stabilize home and profile photo loading --- .../Core/Auth/HGAuthenticationService.swift | 2 + .../Core/Auth/HGProfilePhotoService.swift | 47 +++++++++++++- heatguard-iOS/Core/Network/HGAPIClient.swift | 55 +++++++++++++++- .../Features/Home/HGMenuDrawer.swift | 9 ++- .../Presentation/Features/Home/HomeView.swift | 64 ++++++++++++++----- 5 files changed, 154 insertions(+), 23 deletions(-) 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..50ac84a 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,32 @@ 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) 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) status=\(httpResponse.statusCode) serverCode=\(serverCode, privacy: .public) requestId=\(requestID, privacy: .public) elapsedMs=\(elapsedMilliseconds)" + ) + } guard (200 ... 299).contains(httpResponse.statusCode) else { throw responseError(from: data, statusCode: httpResponse.statusCode) @@ -171,10 +202,23 @@ 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) 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 +260,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..82b9e2f 100644 --- a/heatguard-iOS/Presentation/Features/Home/HomeView.swift +++ b/heatguard-iOS/Presentation/Features/Home/HomeView.swift @@ -826,28 +826,60 @@ struct HomeView: View { } private func loadDashboard() async { - do { - let loadedDashboard = try await HGDashboardService().fetchHomeDashboard() - dashboard = loadedDashboard - unreadNotificationCount = loadedDashboard.unreadNotificationCount - checklist = loadedDashboard.checklist - dashboardError = nil - + 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, isRetryableDashboardFailure(error) else { + dashboardError = HGErrorPresentation(error: error) + return + } + + do { + try await Task.sleep(nanoseconds: 350_000_000) + } catch { + return + } } - } catch { - dashboardError = HGErrorPresentation(error: error) } } + private func isRetryableDashboardFailure(_ 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) + } + @MainActor private func loadProfile() async { teamProfile = try? await HGAuthenticationService().currentProfile() From ff2642cb0a804e7733986c93f3eee369ff7c4653 Mon Sep 17 00:00:00 2001 From: Book-on <266720604+dltldn1234@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:36:53 +0900 Subject: [PATCH 2/2] fix: [#398] satisfy home loading lint rules --- heatguard-iOS/Core/Network/HGAPIClient.swift | 18 ++++++++++++++--- .../Presentation/Features/Home/HomeView.swift | 20 +++++++++++-------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/heatguard-iOS/Core/Network/HGAPIClient.swift b/heatguard-iOS/Core/Network/HGAPIClient.swift index 50ac84a..da88c3e 100644 --- a/heatguard-iOS/Core/Network/HGAPIClient.swift +++ b/heatguard-iOS/Core/Network/HGAPIClient.swift @@ -169,7 +169,10 @@ struct HGAPIClient { 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) diagnostic=\(diagnostic, privacy: .public) elapsedMs=\(elapsedMilliseconds)" + "Home request failed method=\(method, privacy: .public) path=\(path, privacy: .public)" + ) + Self.homeRequestLogger.error( + "diagnostic=\(diagnostic, privacy: .public) elapsedMs=\(elapsedMilliseconds)" ) } throw error @@ -180,7 +183,13 @@ struct HGAPIClient { 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) status=\(httpResponse.statusCode) serverCode=\(serverCode, privacy: .public) requestId=\(requestID, privacy: .public) elapsedMs=\(elapsedMilliseconds)" + "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)" ) } @@ -204,7 +213,10 @@ struct HGAPIClient { } catch { if isHomeRequest { Self.homeRequestLogger.error( - "Home response decode failed path=\(path, privacy: .public) status=\(httpResponse.statusCode) requestId=\(Self.requestID(from: data) ?? "unavailable", privacy: .public)" + "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 diff --git a/heatguard-iOS/Presentation/Features/Home/HomeView.swift b/heatguard-iOS/Presentation/Features/Home/HomeView.swift index 82b9e2f..330be8b 100644 --- a/heatguard-iOS/Presentation/Features/Home/HomeView.swift +++ b/heatguard-iOS/Presentation/Features/Home/HomeView.swift @@ -825,7 +825,14 @@ struct HomeView: View { Binding(get: { storedDraftError != nil }, set: { if !$0 { storedDraftError = nil } }) } - private func loadDashboard() async { + @MainActor + private func loadProfile() async { + teamProfile = try? await HGAuthenticationService().currentProfile() + } +} + +extension HomeView { + fileprivate func loadDashboard() async { for attempt in 0 ... 1 { do { let loadedDashboard = try await HGDashboardService().fetchHomeDashboard() @@ -847,7 +854,7 @@ struct HomeView: View { return } catch { guard !error.hgIsNetworkCancellation else { return } - guard attempt == 0, isRetryableDashboardFailure(error) else { + guard attempt == 0, HGDashboardRetryPolicy.shouldRetry(error) else { dashboardError = HGErrorPresentation(error: error) return } @@ -860,8 +867,10 @@ struct HomeView: View { } } } +} - private func isRetryableDashboardFailure(_ error: Error) -> Bool { +private enum HGDashboardRetryPolicy { + static func shouldRetry(_ error: Error) -> Bool { if let urlError = error as? URLError { return [ .timedOut, @@ -879,11 +888,6 @@ struct HomeView: View { } return statusCode == 408 || statusCode == 429 || (500 ... 599).contains(statusCode) } - - @MainActor - private func loadProfile() async { - teamProfile = try? await HGAuthenticationService().currentProfile() - } } private enum EmergencySheet: Identifiable, Equatable {