231 lines
9.5 KiB
Swift
231 lines
9.5 KiB
Swift
//
|
|
// NotificationService.swift
|
|
// SolarNotifyService
|
|
//
|
|
// Created by LittleSheep on 2024/12/8.
|
|
//
|
|
|
|
import UserNotifications
|
|
import Intents
|
|
import Kingfisher
|
|
import UniformTypeIdentifiers
|
|
|
|
enum ParseNotificationPayloadError: Error {
|
|
case missingMetadata(String)
|
|
case missingAvatarUrl(String)
|
|
}
|
|
|
|
class NotificationService: UNNotificationServiceExtension {
|
|
|
|
private var contentHandler: ((UNNotificationContent) -> Void)?
|
|
private var bestAttemptContent: UNMutableNotificationContent?
|
|
|
|
override func didReceive(
|
|
_ request: UNNotificationRequest,
|
|
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
|
) {
|
|
self.contentHandler = contentHandler
|
|
guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
|
contentHandler(request.content)
|
|
return
|
|
}
|
|
self.bestAttemptContent = bestAttemptContent
|
|
|
|
do {
|
|
try processNotification(request: request, content: bestAttemptContent)
|
|
} catch {
|
|
contentHandler(bestAttemptContent)
|
|
}
|
|
}
|
|
|
|
override func serviceExtensionTimeWillExpire() {
|
|
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
|
|
contentHandler(bestAttemptContent)
|
|
}
|
|
}
|
|
|
|
private func processNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
|
|
switch content.categoryIdentifier {
|
|
case "messaging.message", "messaging.callStart":
|
|
try handleMessagingNotification(request: request, content: content)
|
|
default:
|
|
try handleDefaultNotification(content: content)
|
|
}
|
|
}
|
|
|
|
private func handleMessagingNotification(request: UNNotificationRequest, content: UNMutableNotificationContent) throws {
|
|
guard let metadata = content.userInfo["metadata"] as? [AnyHashable: Any] else {
|
|
throw ParseNotificationPayloadError.missingMetadata("The notification has no metadata.")
|
|
}
|
|
|
|
guard let avatarIdentifier = metadata["avatar"] as? String else {
|
|
throw ParseNotificationPayloadError.missingAvatarUrl("The notification has no avatar.")
|
|
}
|
|
|
|
let replyableMessageCategory = UNNotificationCategory(
|
|
identifier: content.categoryIdentifier,
|
|
actions: [
|
|
UNTextInputNotificationAction(
|
|
identifier: "reply_action",
|
|
title: "Reply",
|
|
options: []
|
|
),
|
|
],
|
|
intentIdentifiers: [],
|
|
options: []
|
|
)
|
|
|
|
UNUserNotificationCenter.current().setNotificationCategories([replyableMessageCategory])
|
|
content.categoryIdentifier = replyableMessageCategory.identifier
|
|
|
|
let metadataCopy = metadata as? [String: String] ?? [:]
|
|
let avatarUrl = getAttachmentUrl(for: avatarIdentifier)
|
|
|
|
let targetSize = 640
|
|
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
|
|
|
|
KingfisherManager.shared.retrieveImage(with: URL(string: avatarUrl)!, options: [.processor(scaleProcessor)], completionHandler: { result in
|
|
var image: Data?
|
|
switch result {
|
|
case .success(let value):
|
|
image = value.image.pngData()
|
|
case .failure(let error):
|
|
print("Unable to get avatar url: \(error)")
|
|
}
|
|
|
|
let handle = INPersonHandle(value: "\(metadataCopy["user_id"] ?? "")", type: .unknown)
|
|
let sender = INPerson(
|
|
personHandle: handle,
|
|
nameComponents: nil,
|
|
displayName: content.title,
|
|
image: image == nil ? nil : INImage(imageData: image!),
|
|
contactIdentifier: nil,
|
|
customIdentifier: nil
|
|
)
|
|
|
|
if content.categoryIdentifier == "messaging.callStart" {
|
|
let intent = self.createCallIntent(with: sender)
|
|
self.donateInteraction(for: intent)
|
|
let updatedContent = try? request.content.updating(from: intent)
|
|
self.contentHandler?(updatedContent ?? content)
|
|
} else {
|
|
let intent = self.createMessageIntent(with: sender, metadata: metadataCopy, body: content.body)
|
|
self.donateInteraction(for: intent)
|
|
let updatedContent = try? request.content.updating(from: intent)
|
|
self.contentHandler?(updatedContent ?? content)
|
|
}
|
|
})
|
|
}
|
|
|
|
private func handleDefaultNotification(content: UNMutableNotificationContent) throws {
|
|
guard let metadata = content.userInfo["metadata"] as? [AnyHashable: Any] else {
|
|
throw ParseNotificationPayloadError.missingMetadata("The notification has no metadata.")
|
|
}
|
|
|
|
if let imageIdentifier = metadata["image"] as? String {
|
|
attachMedia(to: content, withIdentifier: imageIdentifier, fileType: UTType.jpeg, doScaleDown: true)
|
|
} else if let avatarIdentifier = metadata["avatar"] as? String {
|
|
attachMedia(to: content, withIdentifier: avatarIdentifier, fileType: UTType.jpeg, doScaleDown: true)
|
|
} else {
|
|
contentHandler?(content)
|
|
}
|
|
}
|
|
|
|
private func attachMedia(to content: UNMutableNotificationContent, withIdentifier identifier: String, fileType type: UTType?, doScaleDown scaleDown: Bool = false) {
|
|
let attachmentUrl = getAttachmentUrl(for: identifier)
|
|
|
|
guard let remoteUrl = URL(string: attachmentUrl) else {
|
|
print("Invalid URL for attachment: \(attachmentUrl)")
|
|
return
|
|
}
|
|
|
|
let targetSize = 800
|
|
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
|
|
|
|
KingfisherManager.shared.retrieveImage(with: remoteUrl, options: scaleDown ? [
|
|
.processor(scaleProcessor)
|
|
] : nil) { [weak self] result in
|
|
guard let self = self else { return }
|
|
|
|
switch result {
|
|
case .success(let retrievalResult):
|
|
// The image is either retrieved from cache or downloaded
|
|
let tempDirectory = FileManager.default.temporaryDirectory
|
|
let cachedFileUrl = tempDirectory.appendingPathComponent(identifier)
|
|
|
|
do {
|
|
// Write the image data to a temporary file for UNNotificationAttachment
|
|
try retrievalResult.image.pngData()?.write(to: cachedFileUrl)
|
|
self.attachLocalMedia(to: content, fileType: type?.identifier, from: cachedFileUrl, withIdentifier: identifier)
|
|
} catch {
|
|
print("Failed to write media to temporary file: \(error.localizedDescription)")
|
|
self.contentHandler?(content)
|
|
}
|
|
|
|
case .failure(let error):
|
|
print("Failed to retrieve image: \(error.localizedDescription)")
|
|
self.contentHandler?(content)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func attachLocalMedia(to content: UNMutableNotificationContent, fileType type: String?, from localUrl: URL, withIdentifier identifier: String) {
|
|
do {
|
|
let attachment = try UNNotificationAttachment(identifier: identifier, url: localUrl, options: [
|
|
UNNotificationAttachmentOptionsTypeHintKey: type as Any,
|
|
UNNotificationAttachmentOptionsThumbnailHiddenKey: 0,
|
|
])
|
|
content.attachments = [attachment]
|
|
} catch let error as NSError {
|
|
// Log detailed error information
|
|
print("Failed to create attachment from file at \(localUrl.path)")
|
|
print("Error: \(error.localizedDescription)")
|
|
|
|
// Check specific error codes if needed
|
|
if error.domain == NSCocoaErrorDomain {
|
|
switch error.code {
|
|
case NSFileReadNoSuchFileError:
|
|
print("File does not exist at \(localUrl.path)")
|
|
case NSFileReadNoPermissionError:
|
|
print("No permission to read file at \(localUrl.path)")
|
|
default:
|
|
print("Unhandled file error: \(error.code)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Call content handler regardless of success or failure
|
|
self.contentHandler?(content)
|
|
}
|
|
|
|
private func createCallIntent(with sender: INPerson) -> INStartCallIntent {
|
|
INStartCallIntent(
|
|
callRecordFilter: nil,
|
|
callRecordToCallBack: nil,
|
|
audioRoute: .unknown,
|
|
destinationType: .normal,
|
|
contacts: [sender],
|
|
callCapability: .unknown
|
|
)
|
|
}
|
|
|
|
private func createMessageIntent(with sender: INPerson, metadata: [AnyHashable: Any], body: String) -> INSendMessageIntent {
|
|
INSendMessageIntent(
|
|
recipients: nil,
|
|
outgoingMessageType: .outgoingMessageText,
|
|
content: body,
|
|
speakableGroupName: nil,
|
|
conversationIdentifier: "\(metadata["channel_id"] ?? "")",
|
|
serviceName: nil,
|
|
sender: sender,
|
|
attachments: nil
|
|
)
|
|
}
|
|
|
|
private func donateInteraction(for intent: INIntent) {
|
|
let interaction = INInteraction(intent: intent, response: nil)
|
|
interaction.direction = .incoming
|
|
interaction.donate(completion: nil)
|
|
}
|
|
}
|