From 802d32e9a050b0c1d628a3491585734aad9c94e6 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 24 May 2026 23:53:40 +0700 Subject: [PATCH] feat: implement Live Activity registration service and enhance notifications handling --- Mayday.xcodeproj/project.pbxproj | 12 ++ Mayday/Localizable.xcstrings | 16 +++ Mayday/Mayday.entitlements | 5 +- Mayday/Models/AlertAttributes.swift | 58 +++++++- Mayday/Models/AppNotification.swift | 12 +- Mayday/Services/AuthService.swift | 29 +++- Mayday/Services/DevicePlatform.swift | 25 ++++ Mayday/Services/HTTPClient.swift | 115 ++++++++++++---- .../LiveActivityRegistrationService.swift | 127 ++++++++++++++++++ Mayday/Services/NotificationsAPIService.swift | 10 +- Mayday/Services/PushNotificationService.swift | 118 +--------------- Mayday/ViewModels/AuthViewModel.swift | 2 + .../ViewModels/NotificationsViewModel.swift | 4 +- 13 files changed, 379 insertions(+), 154 deletions(-) create mode 100644 Mayday/Services/DevicePlatform.swift create mode 100644 Mayday/Services/LiveActivityRegistrationService.swift diff --git a/Mayday.xcodeproj/project.pbxproj b/Mayday.xcodeproj/project.pbxproj index 219676f..6711ff3 100644 --- a/Mayday.xcodeproj/project.pbxproj +++ b/Mayday.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ AA000001000011 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000011 /* AuthService.swift */; }; AA000001000012 /* NotificationsAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000012 /* NotificationsAPIService.swift */; }; AA000001000013 /* PushNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000013 /* PushNotificationService.swift */; }; + AA000001000099 /* LiveActivityRegistrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000099 /* LiveActivityRegistrationService.swift */; }; + AA000001000100 /* DevicePlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000100 /* DevicePlatform.swift */; }; AA000001000014 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000014 /* AuthViewModel.swift */; }; AA000001000015 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000015 /* NotificationsViewModel.swift */; }; AA000001000016 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000016 /* SettingsViewModel.swift */; }; @@ -86,6 +88,8 @@ AA000002000011 /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = ""; }; AA000002000012 /* NotificationsAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsAPIService.swift; sourceTree = ""; }; AA000002000013 /* PushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationService.swift; sourceTree = ""; }; + AA000002000099 /* LiveActivityRegistrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistrationService.swift; sourceTree = ""; }; + AA000002000100 /* DevicePlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePlatform.swift; sourceTree = ""; }; AA000002000014 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; AA000002000015 /* NotificationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewModel.swift; sourceTree = ""; }; AA000002000016 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -183,6 +187,8 @@ AA000002000011 /* AuthService.swift */, AA000002000012 /* NotificationsAPIService.swift */, AA000002000013 /* PushNotificationService.swift */, + AA000002000099 /* LiveActivityRegistrationService.swift */, + AA000002000100 /* DevicePlatform.swift */, ); path = Services; sourceTree = ""; @@ -386,6 +392,8 @@ AA000001000011 /* AuthService.swift in Sources */, AA000001000012 /* NotificationsAPIService.swift in Sources */, AA000001000013 /* PushNotificationService.swift in Sources */, + AA000001000099 /* LiveActivityRegistrationService.swift in Sources */, + AA000001000100 /* DevicePlatform.swift in Sources */, AA000001000014 /* AuthViewModel.swift in Sources */, AA000001000015 /* NotificationsViewModel.swift in Sources */, AA000001000016 /* SettingsViewModel.swift in Sources */, @@ -434,6 +442,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = WA8SWY233K; @@ -449,6 +458,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -465,6 +475,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = WA8SWY233K; @@ -480,6 +491,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/Mayday/Localizable.xcstrings b/Mayday/Localizable.xcstrings index c7ef335..93e086d 100644 --- a/Mayday/Localizable.xcstrings +++ b/Mayday/Localizable.xcstrings @@ -303,6 +303,22 @@ } } }, + "error_mfa_required" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Two-factor authentication is required. Sign in on the web and disable it, or wait for in-app MFA support." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется двухфакторная аутентификация. Войдите в веб-версии и отключите её, либо дождитесь поддержки MFA в приложении." + } + } + } + }, "error_invalid_url" : { "localizations" : { "en" : { diff --git a/Mayday/Mayday.entitlements b/Mayday/Mayday.entitlements index 0c67376..903def2 100644 --- a/Mayday/Mayday.entitlements +++ b/Mayday/Mayday.entitlements @@ -1,5 +1,8 @@ - + + aps-environment + development + diff --git a/Mayday/Models/AlertAttributes.swift b/Mayday/Models/AlertAttributes.swift index e830281..54535c2 100644 --- a/Mayday/Models/AlertAttributes.swift +++ b/Mayday/Models/AlertAttributes.swift @@ -14,9 +14,61 @@ struct AlertAttributes: ActivityAttributes { let updatedAt: Date enum CodingKeys: String, CodingKey { - case title, value, status - case startedAt = "startedAt" - case updatedAt = "updatedAt" + case title, value, status, startedAt, updatedAt + } + + init(title: String, value: String?, status: AlertStatus, startedAt: Date, updatedAt: Date) { + self.title = title + self.value = value + self.status = status + self.startedAt = startedAt + self.updatedAt = updatedAt + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + title = try c.decode(String.self, forKey: .title) + value = try c.decodeIfPresent(String.self, forKey: .value) + status = try c.decode(AlertStatus.self, forKey: .status) + startedAt = try Self.decodeDate(from: c, forKey: .startedAt) + updatedAt = try Self.decodeDate(from: c, forKey: .updatedAt) + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(title, forKey: .title) + try c.encodeIfPresent(value, forKey: .value) + try c.encode(status, forKey: .status) + try c.encode(Self.isoFormatter.string(from: startedAt), forKey: .startedAt) + try c.encode(Self.isoFormatter.string(from: updatedAt), forKey: .updatedAt) + } + + // ActivityKit uses the default JSONDecoder when materialising ContentState + // from a remote push payload, so dates round-trip as ISO8601 strings + // matching the backend's time.RFC3339[Nano] output. Both formatters are + // pre-built and treated as immutable; ISO8601DateFormatter is documented + // thread-safe for read use. + nonisolated(unsafe) private static let isoWithFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + nonisolated(unsafe) private static let isoWithoutFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + // Single shared encoder format: ISO8601 without fractional seconds, + // since iOS reconstructs Date from string and sub-second precision is + // irrelevant for the alert timestamps we surface. + private static var isoFormatter: ISO8601DateFormatter { isoWithoutFractional } + + private static func decodeDate(from c: KeyedDecodingContainer, forKey key: CodingKeys) throws -> Date { + let s = try c.decode(String.self, forKey: key) + if let d = isoWithFractional.date(from: s) ?? isoWithoutFractional.date(from: s) { + return d + } + throw DecodingError.dataCorruptedError(forKey: key, in: c, debugDescription: "Invalid ISO8601 date: \(s)") } } } diff --git a/Mayday/Models/AppNotification.swift b/Mayday/Models/AppNotification.swift index 6f63b7a..fb308ad 100644 --- a/Mayday/Models/AppNotification.swift +++ b/Mayday/Models/AppNotification.swift @@ -69,17 +69,23 @@ enum ContentType: String, Codable, Sendable { case markdown } -struct NotificationsPage: Codable, Sendable { +struct NotificationsList: Decodable, Sendable { let notifications: [AppNotification] - let total: Int let unreadCount: Int enum CodingKeys: String, CodingKey { - case notifications, total + case notifications case unreadCount = "unread_count" } } +struct NotificationsPage: Sendable { + let notifications: [AppNotification] + let unreadCount: Int + let total: Int + let hasMore: Bool +} + struct DeviceToken: Codable, Identifiable, Sendable { let id: UUID let userId: UUID diff --git a/Mayday/Services/AuthService.swift b/Mayday/Services/AuthService.swift index 5362a0a..0ba7284 100644 --- a/Mayday/Services/AuthService.swift +++ b/Mayday/Services/AuthService.swift @@ -1,8 +1,21 @@ import Foundation +enum LoginStatus: String, Decodable, Sendable { + case authenticated + case mfaRequired = "mfa_required" +} + struct LoginResponse: Decodable, Sendable { - let user: UserResponse - let tokens: TokenPair + let status: LoginStatus + let user: UserResponse? + let tokens: TokenPair? + let mfaToken: String? + let methods: [String]? + + enum CodingKeys: String, CodingKey { + case status, user, tokens, methods + case mfaToken = "mfa_token" + } } struct MessageResponse: Decodable, Sendable { @@ -18,8 +31,16 @@ actor AuthService { func login(email: String, password: String) async throws -> UserResponse { let response: LoginResponse = try await client.request(.login(email: email, password: password)) - try keychain.saveTokens(response.tokens) - return response.user + switch response.status { + case .authenticated: + guard let user = response.user, let tokens = response.tokens else { + throw APIError.serverError("Malformed login response") + } + try keychain.saveTokens(tokens) + return user + case .mfaRequired: + throw APIError.mfaRequired + } } func register(email: String, password: String) async throws -> UserResponse { diff --git a/Mayday/Services/DevicePlatform.swift b/Mayday/Services/DevicePlatform.swift new file mode 100644 index 0000000..8ddbccc --- /dev/null +++ b/Mayday/Services/DevicePlatform.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Wire-format identifiers for the `platform` field on the notification +/// service's `/devices` endpoint. Kept in sync with the backend's +/// `model.DevicePlatform` constants. +enum DevicePlatform { + static let ios = "ios" + static let iosLiveActivityStart = "ios-liveactivity" + + /// Builds a per-activity update token platform tag. The suffix is the + /// `alertId` the Activity was started for, used by the backend to target + /// update/end pushes at a specific running Live Activity. + static func iosLiveActivityUpdate(alertId: String) -> String { + "ios-liveactivity-\(alertId)" + } +} + +enum DeviceTokenFormatter { + /// APNs returns the device token as raw bytes; the HTTP/2 endpoint expects + /// a lowercase hex string. Used uniformly for APNs, Push-to-Start and + /// per-activity update tokens. + static func hex(_ data: Data) -> String { + data.map { String(format: "%02.2hhx", $0) }.joined() + } +} diff --git a/Mayday/Services/HTTPClient.swift b/Mayday/Services/HTTPClient.swift index 293e3ba..7c93946 100644 --- a/Mayday/Services/HTTPClient.swift +++ b/Mayday/Services/HTTPClient.swift @@ -3,6 +3,7 @@ import Foundation enum APIError: Error, LocalizedError { case invalidURL case unauthorized + case mfaRequired case validationError([String: [String]]) case serverError(String) case networkError(Error) @@ -12,6 +13,7 @@ enum APIError: Error, LocalizedError { switch self { case .invalidURL: return String(localized: "error_invalid_url") case .unauthorized: return String(localized: "error_invalid_credentials") + case .mfaRequired: return String(localized: "error_mfa_required") case .validationError(let errors): return errors.values.flatMap { $0 }.joined(separator: ", ") case .serverError(let message): return message @@ -25,6 +27,23 @@ struct APIResponse: Decodable { let data: T } +struct Pagination: Decodable, Sendable { + let total: Int + let limit: Int + let offset: Int + let hasMore: Bool + + enum CodingKeys: String, CodingKey { + case total, limit, offset + case hasMore = "has_more" + } +} + +struct PaginatedResponse: Decodable { + let data: T + let pagination: Pagination +} + struct APIErrorResponse: Decodable { let message: String let errors: [String: [String]]? @@ -57,6 +76,7 @@ enum Endpoint { case listDevices case registerDevice(token: String, platform: String) case unregisterDevice(id: UUID) + case unregisterDeviceByToken(token: String) // Preferences case getPreferences case upsertPreference(channel: String, enabled: Bool, config: [String: String]?) @@ -67,7 +87,7 @@ enum Endpoint { .getMe, .getSessions, .deleteSession, .logoutAll, .changePassword: return .sso case .getNotifications, .markAsRead, .markAllAsRead, - .listDevices, .registerDevice, .unregisterDevice, + .listDevices, .registerDevice, .unregisterDevice, .unregisterDeviceByToken, .getPreferences, .upsertPreference: return .notification } @@ -92,6 +112,7 @@ enum Endpoint { case .listDevices: return "/devices" case .registerDevice: return "/devices" case .unregisterDevice(let id): return "/devices/\(id.uuidString)" + case .unregisterDeviceByToken: return "/devices/by-token" case .getPreferences: return "/preferences" case .upsertPreference: return "/preferences" } @@ -101,7 +122,7 @@ enum Endpoint { switch self { case .getMe, .getSessions, .getNotifications, .listDevices, .getPreferences: return "GET" - case .deleteSession, .unregisterDevice: + case .deleteSession, .unregisterDevice, .unregisterDeviceByToken: return "DELETE" case .upsertPreference: return "PUT" @@ -149,6 +170,8 @@ enum Endpoint { var params: [String: Any] = ["channel": channel, "enabled": enabled] if let config { params["config"] = config } return params + case .unregisterDeviceByToken(let token): + return ["token": token] default: return nil } @@ -165,13 +188,8 @@ actor HTTPClient { private var refreshTask: Task? private init() { - #if DEBUG - ssoBaseURL = "http://192.168.3.7:8081" - notificationBaseURL = "http://192.168.3.7:8092" - #else ssoBaseURL = "https://id.robonen.ru" notificationBaseURL = "https://notify.robonen.ru" - #endif } private func baseURL(for service: APIService) -> String { @@ -182,10 +200,61 @@ actor HTTPClient { } func request(_ endpoint: Endpoint) async throws -> T { - try await performRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth) + let data = try await executeRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth) + + // 204 No Content — return empty decodable if possible + if data.isEmpty { + if let empty = EmptyResponse() as? T { + return empty + } + } + + do { + let wrapped = try Self.jsonDecoder.decode(APIResponse.self, from: data) + return wrapped.data + } catch { + throw APIError.decodingError(error) + } } - private func performRequest(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> T { + func requestPaginated(_ endpoint: Endpoint) async throws -> (T, Pagination) { + let data = try await executeRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth) + do { + let wrapped = try Self.jsonDecoder.decode(PaginatedResponse.self, from: data) + return (wrapped.data, wrapped.pagination) + } catch { + throw APIError.decodingError(error) + } + } + + // Go's default time.Time marshaling uses RFC3339Nano (fractional seconds), + // which the built-in .iso8601 strategy rejects. Two pre-built formatters + // cover both forms; ISO8601DateFormatter is documented thread-safe for read. + nonisolated(unsafe) private static let isoWithFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + nonisolated(unsafe) private static let isoWithoutFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static let jsonDecoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { d in + let container = try d.singleValueContainer() + let s = try container.decode(String.self) + if let date = isoWithFractional.date(from: s) ?? isoWithoutFractional.date(from: s) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO8601 date: \(s)") + } + return decoder + }() + + private func executeRequest(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> Data { guard let url = URL(string: baseURL(for: endpoint.service) + endpoint.path) else { throw APIError.invalidURL } @@ -199,8 +268,9 @@ actor HTTPClient { } if let body = endpoint.body { - if endpoint.method == "GET" { - // Append query parameters to URL for GET requests + // DELETE/GET don't carry a JSON body; encode params on the URL instead + // so endpoints like /devices/by-token?token=... work. + if endpoint.method == "GET" || endpoint.method == "DELETE" { if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) { components.queryItems = body.map { key, value in URLQueryItem(name: key, value: "\(value)") @@ -233,7 +303,7 @@ actor HTTPClient { } catch { throw APIError.unauthorized } - return try await performRequest(endpoint, retryOnUnauthorized: false) + return try await executeRequest(endpoint, retryOnUnauthorized: false) } if httpResponse.statusCode == 401 { @@ -254,21 +324,11 @@ actor HTTPClient { throw APIError.serverError("HTTP \(httpResponse.statusCode)") } - // 204 No Content — return empty decodable if possible - if httpResponse.statusCode == 204 || data.isEmpty { - if let empty = EmptyResponse() as? T { - return empty - } + if httpResponse.statusCode == 204 { + return Data() } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - do { - let wrapped = try decoder.decode(APIResponse.self, from: data) - return wrapped.data - } catch { - throw APIError.decodingError(error) - } + return data } /// Ensures tokens are refreshed exactly once even when multiple requests receive 401 @@ -285,10 +345,7 @@ actor HTTPClient { } let task = Task { - let response: TokenRefreshResponse = try await self.performRequest( - .refresh(refreshToken: refreshToken), - retryOnUnauthorized: false - ) + let response: TokenRefreshResponse = try await self.request(.refresh(refreshToken: refreshToken)) try self.keychain.saveTokens(response.tokens) } refreshTask = task diff --git a/Mayday/Services/LiveActivityRegistrationService.swift b/Mayday/Services/LiveActivityRegistrationService.swift new file mode 100644 index 0000000..524db4e --- /dev/null +++ b/Mayday/Services/LiveActivityRegistrationService.swift @@ -0,0 +1,127 @@ +import Foundation +import ActivityKit + +/// Observes Push-to-Start tokens for `AlertAttributes` activities and registers +/// them with the backend so the server can start, update and end Live Activities +/// via APNs. Also cleans up tokens on the server when activities end and when +/// the user logs out. +@MainActor +final class LiveActivityRegistrationService { + static let shared = LiveActivityRegistrationService() + + private var startTokenObserver: Task? + private var activityObserver: Task? + private var perActivityObservers: [String: Task] = [:] + private var perActivityTokens: [String: String] = [:] + private var lastRegisteredStartToken: String? + + private init() {} + + /// Start observing tokens. Safe to call multiple times. + func start() { + guard startTokenObserver == nil else { return } + + if #available(iOS 17.2, *) { + startTokenObserver = Task { [weak self] in + for await tokenData in Activity.pushToStartTokenUpdates { + let token = DeviceTokenFormatter.hex(tokenData) + await self?.registerStartToken(token) + } + } + } + + activityObserver = Task { [weak self] in + for await activity in Activity.activityUpdates { + self?.trackActivity(activity) + } + } + + // Pick up tokens for activities already running (e.g. after relaunch). + for activity in Activity.activities { + trackActivity(activity) + } + } + + /// Cancel all observers and best-effort delete server-side push tokens so + /// the next user on this device doesn't inherit Live Activity routing. + /// Per-activity tokens are owned by the OS (they die when the activity + /// ends), so only the push-to-start token needs an explicit DELETE. + func stop() { + startTokenObserver?.cancel() + startTokenObserver = nil + activityObserver?.cancel() + activityObserver = nil + perActivityObservers.values.forEach { $0.cancel() } + perActivityObservers.removeAll() + perActivityTokens.removeAll() + + if let token = lastRegisteredStartToken { + lastRegisteredStartToken = nil + Task { await Self.unregister(token: token) } + } + } + + private func trackActivity(_ activity: Activity) { + let alertId = activity.attributes.alertId + guard perActivityObservers[alertId] == nil else { return } + + perActivityObservers[alertId] = Task { [weak self] in + // Spawn a child task to drain pushTokenUpdates concurrently with + // the activityStateUpdates loop below. When the activity reaches a + // terminal state we cancel the token loop and clean up. + let tokenTask = Task { [weak self] in + for await tokenData in activity.pushTokenUpdates { + let token = DeviceTokenFormatter.hex(tokenData) + await self?.registerActivityToken(token, alertId: alertId) + } + } + + for await state in activity.activityStateUpdates { + if state == .ended || state == .dismissed { + break + } + } + + tokenTask.cancel() + await self?.cleanupEndedActivity(alertId: alertId) + } + } + + private func cleanupEndedActivity(alertId: String) async { + let token = perActivityTokens.removeValue(forKey: alertId) + perActivityObservers[alertId] = nil + if let token { + await Self.unregister(token: token) + } + } + + private func registerStartToken(_ token: String) async { + guard token != lastRegisteredStartToken else { return } + do { + let _: DeviceToken = try await HTTPClient.shared.request( + .registerDevice(token: token, platform: DevicePlatform.iosLiveActivityStart) + ) + lastRegisteredStartToken = token + } catch { + // Transient — next token update will retry. + } + } + + private func registerActivityToken(_ token: String, alertId: String) async { + do { + let _: DeviceToken = try await HTTPClient.shared.request( + .registerDevice(token: token, platform: DevicePlatform.iosLiveActivityUpdate(alertId: alertId)) + ) + perActivityTokens[alertId] = token + } catch { + // Transient — next token update will retry. + } + } + + /// Fire-and-forget DELETE so tokens are reclaimed on the server. + /// 404s and network errors are silently ignored — the worst case is one + /// stale row that will eventually be purged via APNs 410 dead-lettering. + private static func unregister(token: String) async { + let _: EmptyResponse? = try? await HTTPClient.shared.request(.unregisterDeviceByToken(token: token)) + } +} diff --git a/Mayday/Services/NotificationsAPIService.swift b/Mayday/Services/NotificationsAPIService.swift index 9cf1d11..fa0e595 100644 --- a/Mayday/Services/NotificationsAPIService.swift +++ b/Mayday/Services/NotificationsAPIService.swift @@ -9,7 +9,15 @@ actor NotificationsAPIService { // MARK: - Notifications func getNotifications(limit: Int = 50, offset: Int = 0, unreadOnly: Bool = false, scope: String? = nil) async throws -> NotificationsPage { - try await client.request(.getNotifications(limit: limit, offset: offset, unreadOnly: unreadOnly, scope: scope)) + let (list, pagination): (NotificationsList, Pagination) = try await client.requestPaginated( + .getNotifications(limit: limit, offset: offset, unreadOnly: unreadOnly, scope: scope) + ) + return NotificationsPage( + notifications: list.notifications, + unreadCount: list.unreadCount, + total: pagination.total, + hasMore: pagination.hasMore + ) } func markAsRead(id: UUID) async throws { diff --git a/Mayday/Services/PushNotificationService.swift b/Mayday/Services/PushNotificationService.swift index 58e77f4..17757a3 100644 --- a/Mayday/Services/PushNotificationService.swift +++ b/Mayday/Services/PushNotificationService.swift @@ -1,14 +1,11 @@ import Foundation import UserNotifications import UIKit -import ActivityKit @MainActor -class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCenterDelegate { +final class PushNotificationService: NSObject, UNUserNotificationCenterDelegate { static let shared = PushNotificationService() - @Published var deviceToken: String? - override private init() { super.init() UNUserNotificationCenter.current().delegate = self @@ -29,117 +26,23 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen } func handleDeviceToken(_ tokenData: Data) { - let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined() - deviceToken = token + let token = DeviceTokenFormatter.hex(tokenData) Task { - try? await HTTPClient.shared.request(.registerDevice(token: token, platform: "ios")) as DeviceToken + try? await HTTPClient.shared.request(.registerDevice(token: token, platform: DevicePlatform.ios)) as DeviceToken } } func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async { guard let aps = userInfo["aps"] as? [String: Any] else { return } - // Handle explicit Live Activity push (event inside aps) - if let event = aps["event"] as? String { - await handleLiveActivityPush(event: event, userInfo: userInfo, aps: aps) - return - } - - // Update badge if let badge = aps["badge"] as? Int { try? await UNUserNotificationCenter.current().setBadgeCount(badge) } - // Start a Live Activity from a regular push if metadata contains severity - if let metadata = userInfo["metadata"] as? [String: String], - let severityStr = metadata["severity"], - let severity = Severity(rawValue: severityStr), - let source = userInfo["source"] as? String, - let subject = userInfo["subject"] as? String { - - let alertId = (userInfo["notificationId"] as? String) ?? UUID().uuidString - let contentState = AlertAttributes.ContentState( - title: subject, - value: metadata["value"], - status: .active, - startedAt: Date(), - updatedAt: Date() - ) - await startLiveActivity( - userInfo: userInfo, - contentState: contentState, - topic: source, - alertId: alertId, - severity: severity - ) - } - } - - private func handleLiveActivityPush(event: String, userInfo: [AnyHashable: Any], aps: [String: Any]) async { - guard let contentStateData = aps["content-state"] as? [String: Any], - let contentStateJSON = try? JSONSerialization.data(withJSONObject: contentStateData), - let contentState = try? JSONDecoder.iso8601.decode(AlertAttributes.ContentState.self, from: contentStateJSON) - else { return } - - switch event { - case "start": - if let attributes = userInfo["attributes"] as? [String: Any], - let topic = attributes["topic"] as? String, - let alertId = attributes["alertId"] as? String, - let severityStr = attributes["severity"] as? String, - let severity = Severity(rawValue: severityStr) { - await startLiveActivity(userInfo: userInfo, contentState: contentState, topic: topic, alertId: alertId, severity: severity) - } - case "update": - await updateLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState) - case "end": - await endLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState) - default: - break - } - } - - private func startLiveActivity(userInfo: [AnyHashable: Any], contentState: AlertAttributes.ContentState, topic: String, alertId: String, severity: Severity) async { - // Info-level alerts don't warrant a persistent Live Activity — they are low-priority - // and should only appear as a standard banner notification. - guard severity != .info else { return } - - // Limit to 3 concurrent activities - let currentActivities = Activity.activities - if currentActivities.count >= 3 { - // End the oldest - if let oldest = currentActivities.min(by: { - $0.content.state.startedAt < $1.content.state.startedAt - }) { - let finalState = oldest.content.state - nonisolated(unsafe) let activity = oldest - await activity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate) - } - } - - let attrs = AlertAttributes(topic: topic, alertId: alertId, severity: severity) - _ = try? Activity.request( - attributes: attrs, - content: ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600)) - ) - } - - private func updateLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async { - guard let alertId else { return } - for activity in Activity.activities where activity.attributes.alertId == alertId { - await activity.update(ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600))) - } - } - - private func endLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async { - guard let alertId else { return } - for activity in Activity.activities where activity.attributes.alertId == alertId { - let dismissDate = Date().addingTimeInterval(5 * 60) - await activity.end( - ActivityContent(state: contentState, staleDate: dismissDate), - dismissalPolicy: .after(dismissDate) - ) - } + // Live Activity start/update/end pushes (apns-push-type: liveactivity) + // are handled entirely by the OS via Push-to-Start tokens and per-activity + // pushTokens — they never land in this delegate. Only regular alert/badge/sound + // pushes reach here, and the only thing we owe them is the badge update above. } // MARK: - UNUserNotificationCenterDelegate @@ -160,10 +63,3 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen } } -extension JSONDecoder { - static let iso8601: JSONDecoder = { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder - }() -} diff --git a/Mayday/ViewModels/AuthViewModel.swift b/Mayday/ViewModels/AuthViewModel.swift index f9ff309..033ed2b 100644 --- a/Mayday/ViewModels/AuthViewModel.swift +++ b/Mayday/ViewModels/AuthViewModel.swift @@ -83,6 +83,7 @@ final class AuthViewModel { // Clear anyway keychain.clearTokens() } + LiveActivityRegistrationService.shared.stop() isAuthenticated = false currentUser = nil } @@ -92,5 +93,6 @@ final class AuthViewModel { if granted { PushNotificationService.shared.registerForRemoteNotifications() } + LiveActivityRegistrationService.shared.start() } } diff --git a/Mayday/ViewModels/NotificationsViewModel.swift b/Mayday/ViewModels/NotificationsViewModel.swift index a1d2b15..1cb9860 100644 --- a/Mayday/ViewModels/NotificationsViewModel.swift +++ b/Mayday/ViewModels/NotificationsViewModel.swift @@ -38,7 +38,7 @@ final class NotificationsViewModel { let page = try await service.getNotifications(limit: limit, offset: 0) notifications = page.notifications unreadCount = page.unreadCount - hasMore = notifications.count < page.total + hasMore = page.hasMore updateBadge() } catch { self.error = error.localizedDescription @@ -55,7 +55,7 @@ final class NotificationsViewModel { notifications.append(contentsOf: page.notifications) unreadCount = page.unreadCount currentOffset = nextOffset - hasMore = notifications.count < page.total + hasMore = page.hasMore } catch { self.error = error.localizedDescription }