Files
.github
android
api
assets
ios
Flutter
Runner
Runner.xcodeproj
Runner.xcworkspace
RunnerTests
SolarNotifyService
Info.plist
NotificationService.swift
SolarNotifyService.entitlements
SolarShare
SolarWidget
.gitignore
Podfile
Podfile.lock
SolarWidgetExtension.entitlements
lib
linux
macos
snap
web
windows
.gitignore
.metadata
.roadsignrc
analysis_options.yaml
build.yaml
devtools_options.yaml
firebase.json
pubspec.lock
pubspec.yaml
roadsign.toml
App/ios/SolarNotifyService/NotificationService.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)
}
}