feat: implement Live Activity registration service and enhance notifications handling
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user