Files
org-social-ios/App/Helpers/PushRegistration.swift

91 lines
3.5 KiB
Swift

import Foundation
import UIKit
import UserNotifications
/// Manages APNs device token registration and the push.org-social.org subscription lifecycle.
///
/// Flow:
/// 1. App becomes ready call `requestPermissionAndRegister()`.
/// 2. iOS calls `AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken`
/// that calls `subscribe(deviceToken:)` here.
/// 3. On relay disable or account deletion call `unsubscribe()`.
@MainActor
final class PushRegistration {
static let shared = PushRegistration()
private init() {}
private let pushBaseURL = URL(string: "https://push.org-social.org")!
private let tokenKey = "pushDeviceToken"
// MARK: - Public API
func requestPermissionAndRegister() async {
guard isEligible else { return }
let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .notDetermined:
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
if granted { await register() }
case .authorized, .provisional, .ephemeral:
await register()
default:
break
}
}
func subscribe(deviceToken: Data) async {
let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined()
UserDefaults.standard.set(tokenString, forKey: tokenKey)
guard let feedURL = ownFeedURL else { return }
await post(endpoint: "subscribe", body: ["feed": feedURL.absoluteString,
"device_token": tokenString])
}
func unsubscribe() async {
guard let token = UserDefaults.standard.string(forKey: tokenKey), !token.isEmpty else { return }
await delete(endpoint: "unsubscribe", body: ["device_token": token])
UserDefaults.standard.removeObject(forKey: tokenKey)
}
// MARK: - Private helpers
private var isEligible: Bool {
let useRelay = UserDefaults.standard.object(forKey: "useRelay") as? Bool ?? true
return useRelay && ownFeedURL != nil
}
private var ownFeedURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"),
let url = URL(string: raw), !raw.isEmpty else { return nil }
return url
}
@discardableResult
private func register() async -> Bool {
await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
return true
}
private func post(endpoint: String, body: [String: String]) async {
guard let url = URL(string: "\(pushBaseURL)/\(endpoint)/") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 10
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
_ = try? await URLSession.shared.data(for: request)
}
private func delete(endpoint: String, body: [String: String]) async {
guard let url = URL(string: "\(pushBaseURL)/\(endpoint)/") else { return }
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 10
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
_ = try? await URLSession.shared.data(for: request)
}
}