Files
mayday/Mayday/Services/PushNotificationService.swift
T
copilot-swe-agent[bot] 1eb21c71ce feat: add complete Mayday iOS Xcode project
- Swift 6, SwiftUI, MVVM + async/await architecture
- iOS 17.0 minimum deployment target
- Two targets: Mayday app + MaydayLiveActivity widget extension
- Models: UserResponse, TokenPair, AppNotification, SessionResponse, AlertAttributes
- Services: HTTPClient (actor), AuthService, KeychainService, NotificationsAPIService, PushNotificationService
- ViewModels: AuthViewModel, NotificationsViewModel, SettingsViewModel
- Views: Login/Register/VerifyEmail, NotificationsList/Detail, Settings/ChangePassword/Sessions
- APNs push notifications with UIApplicationDelegate
- ActivityKit Live Activities for Dynamic Island + Lock Screen
- Keychain (Security framework) token storage
- 30-second polling with pagination for notifications
- Xcode project file (project.pbxproj) with correct build phases for both targets

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 23:04:35 +00:00

142 lines
5.3 KiB
Swift

import Foundation
import UserNotifications
import UIKit
import ActivityKit
@MainActor
class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
static let shared = PushNotificationService()
@Published var deviceToken: String?
override private init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
func requestPermission() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .badge, .sound])
return granted
} catch {
return false
}
}
func registerForRemoteNotifications() {
UIApplication.shared.registerForRemoteNotifications()
}
func handleDeviceToken(_ tokenData: Data) {
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
deviceToken = token
Task {
try? await HTTPClient.shared.request(.registerDevice(token: token)) as EmptyResponse
}
}
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async {
guard let aps = userInfo["aps"] as? [String: Any] else { return }
// Handle Live Activity push
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 {
await UIApplication.shared.setApplicationIconBadgeNumber(badge)
}
}
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":
await startLiveActivity(userInfo: userInfo, contentState: contentState)
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) async {
guard 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) else { return }
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.contentState.startedAt < $1.contentState.startedAt
}) {
await oldest.end(ActivityContent(state: oldest.contentState, 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
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .badge, .sound])
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
completionHandler()
}
}
extension JSONDecoder {
static let iso8601: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
}