feat: implement Live Activity registration service and enhance notifications handling
This commit is contained in:
@@ -20,6 +20,8 @@
|
|||||||
AA000001000011 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000011 /* AuthService.swift */; };
|
AA000001000011 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000011 /* AuthService.swift */; };
|
||||||
AA000001000012 /* NotificationsAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000012 /* NotificationsAPIService.swift */; };
|
AA000001000012 /* NotificationsAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000012 /* NotificationsAPIService.swift */; };
|
||||||
AA000001000013 /* PushNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000013 /* PushNotificationService.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 */; };
|
AA000001000014 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000014 /* AuthViewModel.swift */; };
|
||||||
AA000001000015 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000015 /* NotificationsViewModel.swift */; };
|
AA000001000015 /* NotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000015 /* NotificationsViewModel.swift */; };
|
||||||
AA000001000016 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002000016 /* SettingsViewModel.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 = "<group>"; };
|
AA000002000011 /* AuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthService.swift; sourceTree = "<group>"; };
|
||||||
AA000002000012 /* NotificationsAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsAPIService.swift; sourceTree = "<group>"; };
|
AA000002000012 /* NotificationsAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsAPIService.swift; sourceTree = "<group>"; };
|
||||||
AA000002000013 /* PushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationService.swift; sourceTree = "<group>"; };
|
AA000002000013 /* PushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationService.swift; sourceTree = "<group>"; };
|
||||||
|
AA000002000099 /* LiveActivityRegistrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistrationService.swift; sourceTree = "<group>"; };
|
||||||
|
AA000002000100 /* DevicePlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePlatform.swift; sourceTree = "<group>"; };
|
||||||
AA000002000014 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = "<group>"; };
|
AA000002000014 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = "<group>"; };
|
||||||
AA000002000015 /* NotificationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewModel.swift; sourceTree = "<group>"; };
|
AA000002000015 /* NotificationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewModel.swift; sourceTree = "<group>"; };
|
||||||
AA000002000016 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
AA000002000016 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = "<group>"; };
|
||||||
@@ -183,6 +187,8 @@
|
|||||||
AA000002000011 /* AuthService.swift */,
|
AA000002000011 /* AuthService.swift */,
|
||||||
AA000002000012 /* NotificationsAPIService.swift */,
|
AA000002000012 /* NotificationsAPIService.swift */,
|
||||||
AA000002000013 /* PushNotificationService.swift */,
|
AA000002000013 /* PushNotificationService.swift */,
|
||||||
|
AA000002000099 /* LiveActivityRegistrationService.swift */,
|
||||||
|
AA000002000100 /* DevicePlatform.swift */,
|
||||||
);
|
);
|
||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -386,6 +392,8 @@
|
|||||||
AA000001000011 /* AuthService.swift in Sources */,
|
AA000001000011 /* AuthService.swift in Sources */,
|
||||||
AA000001000012 /* NotificationsAPIService.swift in Sources */,
|
AA000001000012 /* NotificationsAPIService.swift in Sources */,
|
||||||
AA000001000013 /* PushNotificationService.swift in Sources */,
|
AA000001000013 /* PushNotificationService.swift in Sources */,
|
||||||
|
AA000001000099 /* LiveActivityRegistrationService.swift in Sources */,
|
||||||
|
AA000001000100 /* DevicePlatform.swift in Sources */,
|
||||||
AA000001000014 /* AuthViewModel.swift in Sources */,
|
AA000001000014 /* AuthViewModel.swift in Sources */,
|
||||||
AA000001000015 /* NotificationsViewModel.swift in Sources */,
|
AA000001000015 /* NotificationsViewModel.swift in Sources */,
|
||||||
AA000001000016 /* SettingsViewModel.swift in Sources */,
|
AA000001000016 /* SettingsViewModel.swift in Sources */,
|
||||||
@@ -434,6 +442,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = WA8SWY233K;
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
@@ -449,6 +458,7 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday;
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
@@ -465,6 +475,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Mayday/Mayday.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = WA8SWY233K;
|
DEVELOPMENT_TEAM = WA8SWY233K;
|
||||||
@@ -480,6 +491,7 @@
|
|||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday;
|
PRODUCT_BUNDLE_IDENTIFIER = com.robonen.mayday;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
|
|||||||
@@ -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" : {
|
"error_invalid_url" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict/>
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,9 +14,61 @@ struct AlertAttributes: ActivityAttributes {
|
|||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case title, value, status
|
case title, value, status, startedAt, updatedAt
|
||||||
case startedAt = "startedAt"
|
}
|
||||||
case updatedAt = "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<CodingKeys>, 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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,17 +69,23 @@ enum ContentType: String, Codable, Sendable {
|
|||||||
case markdown
|
case markdown
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NotificationsPage: Codable, Sendable {
|
struct NotificationsList: Decodable, Sendable {
|
||||||
let notifications: [AppNotification]
|
let notifications: [AppNotification]
|
||||||
let total: Int
|
|
||||||
let unreadCount: Int
|
let unreadCount: Int
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case notifications, total
|
case notifications
|
||||||
case unreadCount = "unread_count"
|
case unreadCount = "unread_count"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct NotificationsPage: Sendable {
|
||||||
|
let notifications: [AppNotification]
|
||||||
|
let unreadCount: Int
|
||||||
|
let total: Int
|
||||||
|
let hasMore: Bool
|
||||||
|
}
|
||||||
|
|
||||||
struct DeviceToken: Codable, Identifiable, Sendable {
|
struct DeviceToken: Codable, Identifiable, Sendable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let userId: UUID
|
let userId: UUID
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
enum LoginStatus: String, Decodable, Sendable {
|
||||||
|
case authenticated
|
||||||
|
case mfaRequired = "mfa_required"
|
||||||
|
}
|
||||||
|
|
||||||
struct LoginResponse: Decodable, Sendable {
|
struct LoginResponse: Decodable, Sendable {
|
||||||
let user: UserResponse
|
let status: LoginStatus
|
||||||
let tokens: TokenPair
|
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 {
|
struct MessageResponse: Decodable, Sendable {
|
||||||
@@ -18,8 +31,16 @@ actor AuthService {
|
|||||||
|
|
||||||
func login(email: String, password: String) async throws -> UserResponse {
|
func login(email: String, password: String) async throws -> UserResponse {
|
||||||
let response: LoginResponse = try await client.request(.login(email: email, password: password))
|
let response: LoginResponse = try await client.request(.login(email: email, password: password))
|
||||||
try keychain.saveTokens(response.tokens)
|
switch response.status {
|
||||||
return response.user
|
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 {
|
func register(email: String, password: String) async throws -> UserResponse {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
enum APIError: Error, LocalizedError {
|
enum APIError: Error, LocalizedError {
|
||||||
case invalidURL
|
case invalidURL
|
||||||
case unauthorized
|
case unauthorized
|
||||||
|
case mfaRequired
|
||||||
case validationError([String: [String]])
|
case validationError([String: [String]])
|
||||||
case serverError(String)
|
case serverError(String)
|
||||||
case networkError(Error)
|
case networkError(Error)
|
||||||
@@ -12,6 +13,7 @@ enum APIError: Error, LocalizedError {
|
|||||||
switch self {
|
switch self {
|
||||||
case .invalidURL: return String(localized: "error_invalid_url")
|
case .invalidURL: return String(localized: "error_invalid_url")
|
||||||
case .unauthorized: return String(localized: "error_invalid_credentials")
|
case .unauthorized: return String(localized: "error_invalid_credentials")
|
||||||
|
case .mfaRequired: return String(localized: "error_mfa_required")
|
||||||
case .validationError(let errors):
|
case .validationError(let errors):
|
||||||
return errors.values.flatMap { $0 }.joined(separator: ", ")
|
return errors.values.flatMap { $0 }.joined(separator: ", ")
|
||||||
case .serverError(let message): return message
|
case .serverError(let message): return message
|
||||||
@@ -25,6 +27,23 @@ struct APIResponse<T: Decodable>: Decodable {
|
|||||||
let data: T
|
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<T: Decodable>: Decodable {
|
||||||
|
let data: T
|
||||||
|
let pagination: Pagination
|
||||||
|
}
|
||||||
|
|
||||||
struct APIErrorResponse: Decodable {
|
struct APIErrorResponse: Decodable {
|
||||||
let message: String
|
let message: String
|
||||||
let errors: [String: [String]]?
|
let errors: [String: [String]]?
|
||||||
@@ -57,6 +76,7 @@ enum Endpoint {
|
|||||||
case listDevices
|
case listDevices
|
||||||
case registerDevice(token: String, platform: String)
|
case registerDevice(token: String, platform: String)
|
||||||
case unregisterDevice(id: UUID)
|
case unregisterDevice(id: UUID)
|
||||||
|
case unregisterDeviceByToken(token: String)
|
||||||
// Preferences
|
// Preferences
|
||||||
case getPreferences
|
case getPreferences
|
||||||
case upsertPreference(channel: String, enabled: Bool, config: [String: String]?)
|
case upsertPreference(channel: String, enabled: Bool, config: [String: String]?)
|
||||||
@@ -67,7 +87,7 @@ enum Endpoint {
|
|||||||
.getMe, .getSessions, .deleteSession, .logoutAll, .changePassword:
|
.getMe, .getSessions, .deleteSession, .logoutAll, .changePassword:
|
||||||
return .sso
|
return .sso
|
||||||
case .getNotifications, .markAsRead, .markAllAsRead,
|
case .getNotifications, .markAsRead, .markAllAsRead,
|
||||||
.listDevices, .registerDevice, .unregisterDevice,
|
.listDevices, .registerDevice, .unregisterDevice, .unregisterDeviceByToken,
|
||||||
.getPreferences, .upsertPreference:
|
.getPreferences, .upsertPreference:
|
||||||
return .notification
|
return .notification
|
||||||
}
|
}
|
||||||
@@ -92,6 +112,7 @@ enum Endpoint {
|
|||||||
case .listDevices: return "/devices"
|
case .listDevices: return "/devices"
|
||||||
case .registerDevice: return "/devices"
|
case .registerDevice: return "/devices"
|
||||||
case .unregisterDevice(let id): return "/devices/\(id.uuidString)"
|
case .unregisterDevice(let id): return "/devices/\(id.uuidString)"
|
||||||
|
case .unregisterDeviceByToken: return "/devices/by-token"
|
||||||
case .getPreferences: return "/preferences"
|
case .getPreferences: return "/preferences"
|
||||||
case .upsertPreference: return "/preferences"
|
case .upsertPreference: return "/preferences"
|
||||||
}
|
}
|
||||||
@@ -101,7 +122,7 @@ enum Endpoint {
|
|||||||
switch self {
|
switch self {
|
||||||
case .getMe, .getSessions, .getNotifications, .listDevices, .getPreferences:
|
case .getMe, .getSessions, .getNotifications, .listDevices, .getPreferences:
|
||||||
return "GET"
|
return "GET"
|
||||||
case .deleteSession, .unregisterDevice:
|
case .deleteSession, .unregisterDevice, .unregisterDeviceByToken:
|
||||||
return "DELETE"
|
return "DELETE"
|
||||||
case .upsertPreference:
|
case .upsertPreference:
|
||||||
return "PUT"
|
return "PUT"
|
||||||
@@ -149,6 +170,8 @@ enum Endpoint {
|
|||||||
var params: [String: Any] = ["channel": channel, "enabled": enabled]
|
var params: [String: Any] = ["channel": channel, "enabled": enabled]
|
||||||
if let config { params["config"] = config }
|
if let config { params["config"] = config }
|
||||||
return params
|
return params
|
||||||
|
case .unregisterDeviceByToken(let token):
|
||||||
|
return ["token": token]
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -165,13 +188,8 @@ actor HTTPClient {
|
|||||||
private var refreshTask: Task<Void, Error>?
|
private var refreshTask: Task<Void, Error>?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
#if DEBUG
|
|
||||||
ssoBaseURL = "http://192.168.3.7:8081"
|
|
||||||
notificationBaseURL = "http://192.168.3.7:8092"
|
|
||||||
#else
|
|
||||||
ssoBaseURL = "https://id.robonen.ru"
|
ssoBaseURL = "https://id.robonen.ru"
|
||||||
notificationBaseURL = "https://notify.robonen.ru"
|
notificationBaseURL = "https://notify.robonen.ru"
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func baseURL(for service: APIService) -> String {
|
private func baseURL(for service: APIService) -> String {
|
||||||
@@ -182,10 +200,61 @@ actor HTTPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
|
func request<T: Decodable>(_ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performRequest<T: Decodable>(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> T {
|
do {
|
||||||
|
let wrapped = try Self.jsonDecoder.decode(APIResponse<T>.self, from: data)
|
||||||
|
return wrapped.data
|
||||||
|
} catch {
|
||||||
|
throw APIError.decodingError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestPaginated<T: Decodable>(_ endpoint: Endpoint) async throws -> (T, Pagination) {
|
||||||
|
let data = try await executeRequest(endpoint, retryOnUnauthorized: endpoint.requiresAuth)
|
||||||
|
do {
|
||||||
|
let wrapped = try Self.jsonDecoder.decode(PaginatedResponse<T>.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 {
|
guard let url = URL(string: baseURL(for: endpoint.service) + endpoint.path) else {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
@@ -199,8 +268,9 @@ actor HTTPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let body = endpoint.body {
|
if let body = endpoint.body {
|
||||||
if endpoint.method == "GET" {
|
// DELETE/GET don't carry a JSON body; encode params on the URL instead
|
||||||
// Append query parameters to URL for GET requests
|
// so endpoints like /devices/by-token?token=... work.
|
||||||
|
if endpoint.method == "GET" || endpoint.method == "DELETE" {
|
||||||
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
if var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
||||||
components.queryItems = body.map { key, value in
|
components.queryItems = body.map { key, value in
|
||||||
URLQueryItem(name: key, value: "\(value)")
|
URLQueryItem(name: key, value: "\(value)")
|
||||||
@@ -233,7 +303,7 @@ actor HTTPClient {
|
|||||||
} catch {
|
} catch {
|
||||||
throw APIError.unauthorized
|
throw APIError.unauthorized
|
||||||
}
|
}
|
||||||
return try await performRequest(endpoint, retryOnUnauthorized: false)
|
return try await executeRequest(endpoint, retryOnUnauthorized: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if httpResponse.statusCode == 401 {
|
if httpResponse.statusCode == 401 {
|
||||||
@@ -254,21 +324,11 @@ actor HTTPClient {
|
|||||||
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 204 No Content — return empty decodable if possible
|
if httpResponse.statusCode == 204 {
|
||||||
if httpResponse.statusCode == 204 || data.isEmpty {
|
return Data()
|
||||||
if let empty = EmptyResponse() as? T {
|
|
||||||
return empty
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
return data
|
||||||
decoder.dateDecodingStrategy = .iso8601
|
|
||||||
do {
|
|
||||||
let wrapped = try decoder.decode(APIResponse<T>.self, from: data)
|
|
||||||
return wrapped.data
|
|
||||||
} catch {
|
|
||||||
throw APIError.decodingError(error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures tokens are refreshed exactly once even when multiple requests receive 401
|
/// Ensures tokens are refreshed exactly once even when multiple requests receive 401
|
||||||
@@ -285,10 +345,7 @@ actor HTTPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let task = Task<Void, Error> {
|
let task = Task<Void, Error> {
|
||||||
let response: TokenRefreshResponse = try await self.performRequest(
|
let response: TokenRefreshResponse = try await self.request(.refresh(refreshToken: refreshToken))
|
||||||
.refresh(refreshToken: refreshToken),
|
|
||||||
retryOnUnauthorized: false
|
|
||||||
)
|
|
||||||
try self.keychain.saveTokens(response.tokens)
|
try self.keychain.saveTokens(response.tokens)
|
||||||
}
|
}
|
||||||
refreshTask = task
|
refreshTask = task
|
||||||
|
|||||||
@@ -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<Void, Never>?
|
||||||
|
private var activityObserver: Task<Void, Never>?
|
||||||
|
private var perActivityObservers: [String: Task<Void, Never>] = [:]
|
||||||
|
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<AlertAttributes>.pushToStartTokenUpdates {
|
||||||
|
let token = DeviceTokenFormatter.hex(tokenData)
|
||||||
|
await self?.registerStartToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activityObserver = Task { [weak self] in
|
||||||
|
for await activity in Activity<AlertAttributes>.activityUpdates {
|
||||||
|
self?.trackActivity(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick up tokens for activities already running (e.g. after relaunch).
|
||||||
|
for activity in Activity<AlertAttributes>.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<AlertAttributes>) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,15 @@ actor NotificationsAPIService {
|
|||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
|
|
||||||
func getNotifications(limit: Int = 50, offset: Int = 0, unreadOnly: Bool = false, scope: String? = nil) async throws -> NotificationsPage {
|
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 {
|
func markAsRead(id: UUID) async throws {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import UIKit
|
import UIKit
|
||||||
import ActivityKit
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
|
final class PushNotificationService: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = PushNotificationService()
|
static let shared = PushNotificationService()
|
||||||
|
|
||||||
@Published var deviceToken: String?
|
|
||||||
|
|
||||||
override private init() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
@@ -29,117 +26,23 @@ class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCen
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleDeviceToken(_ tokenData: Data) {
|
func handleDeviceToken(_ tokenData: Data) {
|
||||||
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
|
let token = DeviceTokenFormatter.hex(tokenData)
|
||||||
deviceToken = token
|
|
||||||
Task {
|
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 {
|
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async {
|
||||||
guard let aps = userInfo["aps"] as? [String: Any] else { return }
|
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 {
|
if let badge = aps["badge"] as? Int {
|
||||||
try? await UNUserNotificationCenter.current().setBadgeCount(badge)
|
try? await UNUserNotificationCenter.current().setBadgeCount(badge)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start a Live Activity from a regular push if metadata contains severity
|
// Live Activity start/update/end pushes (apns-push-type: liveactivity)
|
||||||
if let metadata = userInfo["metadata"] as? [String: String],
|
// are handled entirely by the OS via Push-to-Start tokens and per-activity
|
||||||
let severityStr = metadata["severity"],
|
// pushTokens — they never land in this delegate. Only regular alert/badge/sound
|
||||||
let severity = Severity(rawValue: severityStr),
|
// pushes reach here, and the only thing we owe them is the badge update above.
|
||||||
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<AlertAttributes>.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<AlertAttributes>.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<AlertAttributes>.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<AlertAttributes>.activities where activity.attributes.alertId == alertId {
|
|
||||||
let dismissDate = Date().addingTimeInterval(5 * 60)
|
|
||||||
await activity.end(
|
|
||||||
ActivityContent(state: contentState, staleDate: dismissDate),
|
|
||||||
dismissalPolicy: .after(dismissDate)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UNUserNotificationCenterDelegate
|
// 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
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ final class AuthViewModel {
|
|||||||
// Clear anyway
|
// Clear anyway
|
||||||
keychain.clearTokens()
|
keychain.clearTokens()
|
||||||
}
|
}
|
||||||
|
LiveActivityRegistrationService.shared.stop()
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
currentUser = nil
|
currentUser = nil
|
||||||
}
|
}
|
||||||
@@ -92,5 +93,6 @@ final class AuthViewModel {
|
|||||||
if granted {
|
if granted {
|
||||||
PushNotificationService.shared.registerForRemoteNotifications()
|
PushNotificationService.shared.registerForRemoteNotifications()
|
||||||
}
|
}
|
||||||
|
LiveActivityRegistrationService.shared.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ final class NotificationsViewModel {
|
|||||||
let page = try await service.getNotifications(limit: limit, offset: 0)
|
let page = try await service.getNotifications(limit: limit, offset: 0)
|
||||||
notifications = page.notifications
|
notifications = page.notifications
|
||||||
unreadCount = page.unreadCount
|
unreadCount = page.unreadCount
|
||||||
hasMore = notifications.count < page.total
|
hasMore = page.hasMore
|
||||||
updateBadge()
|
updateBadge()
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
@@ -55,7 +55,7 @@ final class NotificationsViewModel {
|
|||||||
notifications.append(contentsOf: page.notifications)
|
notifications.append(contentsOf: page.notifications)
|
||||||
unreadCount = page.unreadCount
|
unreadCount = page.unreadCount
|
||||||
currentOffset = nextOffset
|
currentOffset = nextOffset
|
||||||
hasMore = notifications.count < page.total
|
hasMore = page.hasMore
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user