Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions heatguard-iOS/Core/Auth/HGAuthenticationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -165,6 +166,7 @@ struct HGAuthenticationService {
}

func endLocalSession(clearDraft: Bool = true) {
HGProfilePhotoCache.shared.clear()
if clearDraft {
try? HGRecordDraftStore(ownerID: sessionState.userID).clear()
}
Expand Down
47 changes: 46 additions & 1 deletion heatguard-iOS/Core/Auth/HGProfilePhotoService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -27,6 +34,7 @@ struct HGProfilePhotoService {
let image = UIImage(data: data) else {
throw HGProfilePhotoError.downloadFailed
}
HGProfilePhotoCache.shared.store(data)
return image
}

Expand All @@ -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
Expand Down Expand Up @@ -114,6 +123,7 @@ struct HGProfilePhotoService {
path: HGAPIPath.teamProfilePhoto,
requiresAuthentication: true
)
HGProfilePhotoCache.shared.clear()
}

private static func jpegData(from image: UIImage) throws -> Data {
Expand Down Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion heatguard-iOS/Core/Network/HGAPIClient.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import OSLog
import Security

enum HGAPIConfiguration {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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<HGEmptyPayload>.self, from: data).meta?.requestID)
}

private static func serverErrorCode(from data: Data) -> String? {
(try? JSONDecoder().decode(HGAPIEnvelope<HGEmptyPayload>.self, from: data).error?.code)
}

private func responseError(from data: Data, statusCode: Int) -> HGAPIError {
let errorEnvelope = try? JSONDecoder().decode(HGAPIEnvelope<HGEmptyPayload>.self, from: data)
let fallbackMessage = HTTPURLResponse.localizedString(forStatusCode: statusCode)
Expand Down Expand Up @@ -216,6 +272,15 @@ struct HGAPIEnvelope<Payload: Decodable>: 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 {
Expand Down
9 changes: 4 additions & 5 deletions heatguard-iOS/Presentation/Features/Home/HGMenuDrawer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}
}
Expand Down
74 changes: 55 additions & 19 deletions heatguard-iOS/Presentation/Features/Home/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading