IceCubesApp/Packages/Env/Sources/Env/PushNotificationsService.swift
2023-02-27 06:39:07 +01:00

253 lines
8.5 KiB
Swift

import Combine
import CryptoKit
import Foundation
import KeychainSwift
import Models
import Network
import SwiftUI
import UserNotifications
public struct PushAccount: Equatable {
public let server: String
public let token: OauthToken
public let accountName: String?
public init(server: String, token: OauthToken, accountName: String?) {
self.server = server
self.token = token
self.accountName = accountName
}
}
public struct HandledNotification: Equatable {
public let account: PushAccount
public let notification: Models.Notification
}
@MainActor
public class PushNotificationsService: NSObject, ObservableObject {
enum Constants {
static let endpoint = "https://icecubesrelay.fly.dev"
static let keychainAuthKey = "notifications_auth_key"
static let keychainPrivateKey = "notifications_private_key"
}
public static let shared = PushNotificationsService()
public private(set) var subscriptions: [PushNotificationSubscriptionSettings] = []
@Published public var pushToken: Data?
@Published public var handledNotification: HandledNotification?
override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
private var keychain: KeychainSwift {
let keychain = KeychainSwift()
#if !DEBUG && !targetEnvironment(simulator)
keychain.accessGroup = AppInfo.keychainGroup
#endif
return keychain
}
public func requestPushNotifications() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
public func setAccounts(accounts: [PushAccount]) {
subscriptions = []
for account in accounts {
let sub = PushNotificationSubscriptionSettings(account: account,
key: notificationsPrivateKeyAsKey.publicKey.x963Representation,
authKey: notificationsAuthKeyAsKey,
pushToken: pushToken)
subscriptions.append(sub)
}
}
public func updateSubscriptions(forceCreate: Bool) async {
for subscription in subscriptions {
await withTaskGroup(of: Void.self, body: { group in
group.addTask {
await subscription.fetchSubscription()
if await subscription.subscription != nil, !forceCreate {
await subscription.deleteSubscription()
await subscription.updateSubscription()
} else if forceCreate {
await subscription.updateSubscription()
}
}
})
}
}
// MARK: - Key management
public var notificationsPrivateKeyAsKey: P256.KeyAgreement.PrivateKey {
if let key = keychain.get(Constants.keychainPrivateKey),
let data = Data(base64Encoded: key)
{
do {
return try P256.KeyAgreement.PrivateKey(rawRepresentation: data)
} catch {
let key = P256.KeyAgreement.PrivateKey()
keychain.set(key.rawRepresentation.base64EncodedString(),
forKey: Constants.keychainPrivateKey,
withAccess: .accessibleAfterFirstUnlock)
return key
}
} else {
let key = P256.KeyAgreement.PrivateKey()
keychain.set(key.rawRepresentation.base64EncodedString(),
forKey: Constants.keychainPrivateKey,
withAccess: .accessibleAfterFirstUnlock)
return key
}
}
public var notificationsAuthKeyAsKey: Data {
if let key = keychain.get(Constants.keychainAuthKey),
let data = Data(base64Encoded: key)
{
return data
} else {
let key = Self.makeRandomNotificationsAuthKey()
keychain.set(key.base64EncodedString(),
forKey: Constants.keychainAuthKey,
withAccess: .accessibleAfterFirstUnlock)
return key
}
}
private static func makeRandomNotificationsAuthKey() -> Data {
let byteCount = 16
var bytes = Data(count: byteCount)
_ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) }
return bytes
}
}
extension PushNotificationsService: UNUserNotificationCenterDelegate {
public func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
guard let plaintext = response.notification.request.content.userInfo["plaintext"] as? Data,
let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext),
let account = subscriptions.first(where: { $0.account.token.accessToken == mastodonPushNotification.accessToken })
else {
return
}
do {
let client = Client(server: account.account.server, oauthToken: account.account.token)
let notification: Models.Notification =
try await client.get(endpoint: Notifications.notification(id: String(mastodonPushNotification.notificationID)))
handledNotification = .init(account: account.account, notification: notification)
} catch {}
}
}
extension Data {
var hexString: String {
return map { String(format: "%02.2hhx", arguments: [$0]) }.joined()
}
}
@MainActor
public class PushNotificationSubscriptionSettings: ObservableObject {
@Published public var isEnabled: Bool = true
@Published public var isFollowNotificationEnabled: Bool = true
@Published public var isFavoriteNotificationEnabled: Bool = true
@Published public var isReblogNotificationEnabled: Bool = true
@Published public var isMentionNotificationEnabled: Bool = true
@Published public var isPollNotificationEnabled: Bool = true
@Published public var isNewPostsNotificationEnabled: Bool = true
public let account: PushAccount
private let key: Data
private let authKey: Data
public var pushToken: Data?
public private(set) var subscription: PushSubscription?
public init(account: PushAccount, key: Data, authKey: Data, pushToken: Data?) {
self.account = account
self.key = key
self.authKey = authKey
self.pushToken = pushToken
}
private func refreshSubscriptionsUI() {
if let subscription {
isFollowNotificationEnabled = subscription.alerts.follow
isFavoriteNotificationEnabled = subscription.alerts.favourite
isReblogNotificationEnabled = subscription.alerts.reblog
isMentionNotificationEnabled = subscription.alerts.mention
isPollNotificationEnabled = subscription.alerts.poll
isNewPostsNotificationEnabled = subscription.alerts.status
}
}
public func updateSubscription() async {
guard let pushToken = pushToken else { return }
let client = Client(server: account.server, oauthToken: account.token)
do {
var listenerURL = PushNotificationsService.Constants.endpoint
listenerURL += "/push/"
listenerURL += pushToken.hexString
listenerURL += "/\(account.accountName ?? account.server)"
#if DEBUG
listenerURL += "?sandbox=true"
#endif
subscription =
try await client.post(endpoint: Push.createSub(endpoint: listenerURL,
p256dh: key,
auth: authKey,
mentions: isMentionNotificationEnabled,
status: isNewPostsNotificationEnabled,
reblog: isReblogNotificationEnabled,
follow: isFollowNotificationEnabled,
favorite: isFavoriteNotificationEnabled,
poll: isPollNotificationEnabled))
isEnabled = subscription != nil
} catch {
isEnabled = false
}
refreshSubscriptionsUI()
}
public func deleteSubscription() async {
let client = Client(server: account.server, oauthToken: account.token)
do {
_ = try await client.delete(endpoint: Push.subscription)
subscription = nil
await fetchSubscription()
refreshSubscriptionsUI()
while subscription != nil {
await deleteSubscription()
}
isEnabled = false
} catch {}
}
public func fetchSubscription() async {
let client = Client(server: account.server, oauthToken: account.token)
do {
subscription = try await client.get(endpoint: Push.subscription)
isEnabled = subscription != nil
} catch {
subscription = nil
isEnabled = false
}
refreshSubscriptionsUI()
}
}