feat: implement Live Activity registration service and enhance notifications handling

This commit is contained in:
2026-05-24 23:53:40 +07:00
parent d991d06f17
commit 802d32e9a0
13 changed files with 379 additions and 154 deletions
+12
View File
@@ -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 = "<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>"; };
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>"; };
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>"; };
@@ -183,6 +187,8 @@
AA000002000011 /* AuthService.swift */,
AA000002000012 /* NotificationsAPIService.swift */,
AA000002000013 /* PushNotificationService.swift */,
AA000002000099 /* LiveActivityRegistrationService.swift */,
AA000002000100 /* DevicePlatform.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -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;
+16
View File
@@ -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" : {
+4 -1
View File
@@ -1,5 +1,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">
<plist version="1.0">
<dict/>
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
+55 -3
View File
@@ -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<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)")
}
}
}
+9 -3
View File
@@ -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
+25 -4
View File
@@ -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 {
+25
View File
@@ -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()
}
}
+86 -29
View File
@@ -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<T: Decodable>: 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<T: Decodable>: 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<Void, Error>?
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<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
}
}
do {
let wrapped = try Self.jsonDecoder.decode(APIResponse<T>.self, from: data)
return wrapped.data
} catch {
throw APIError.decodingError(error)
}
}
private func performRequest<T: Decodable>(_ endpoint: Endpoint, retryOnUnauthorized: Bool) async throws -> T {
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 {
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<T>.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<Void, Error> {
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
@@ -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
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 {
+7 -111
View File
@@ -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<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)
)
}
// 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
}()
}
+2
View File
@@ -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()
}
}
@@ -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
}