-
Notifications
You must be signed in to change notification settings - Fork 480
Add custom notification icons for automations #4672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Roei-Bracha
wants to merge
15
commits into
home-assistant:main
Choose a base branch
from
Roei-Bracha:feature/custom-notification-icons
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from 3 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
7ccd851
Add custom notification icons for automations
Roei-Bracha 8937acb
Address review comments for custom notification icons
Roei-Bracha 2178307
Merge branch 'main' into feature/custom-notification-icons
Roei-Bracha 1b7f5d8
Wrap CarPlay assist session actionButtons configuration in compiler c…
Roei-Bracha f015944
Support notification decoration without API context for local testing
Roei-Bracha 0273aed
Add Communication Notifications entitlement to extensions
Roei-Bracha 5a0c9e2
Decorate local push notifications in LocalPushManager
Roei-Bracha f18784e
feat: support custom notification icons via local push and APNs
Roei-Bracha 09612be
fix: address PR review comments for Equatable conformance and relativ…
Roei-Bracha bde999e
Merge branch 'main' into feature/custom-notification-icons
Roei-Bracha 3c833fb
Address notification icon review feedback
Roei-Bracha db26f5b
Merge branch 'main' into feature/custom-notification-icons
Roei-Bracha 992b1fe
Merge branch 'main' into feature/custom-notification-icons
bgoncal 6a6d3ee
Merge upstream/main into feature/custom-notification-icons
Roei-Bracha 0ac5890
Refactor closure to capture notificationCommunicationDecorator for im…
Roei-Bracha File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
14 changes: 14 additions & 0 deletions
14
...xtensions/NotificationContent/Resources/TestNotifications/communication_notification.apns
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| { | ||
| "aps": { | ||
| "alert": { | ||
| "title": "Dishwasher", | ||
| "body": "Cycle complete." | ||
| }, | ||
| "sound": "default", | ||
| "category": "notification", | ||
| "mutable-content": 1 | ||
| }, | ||
| "notification_icon": "mdi:dishwasher", | ||
| "color": "#4CAF50", | ||
| "webhook_id": "REPLACE_WITH_YOUR_WEBHOOK_ID" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
188 changes: 188 additions & 0 deletions
188
Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,188 @@ | ||||
| import Foundation | ||||
| import ImageIO | ||||
| import Intents | ||||
| import PromiseKit | ||||
| import UIKit | ||||
| import UserNotifications | ||||
|
|
||||
| public protocol NotificationCommunicationDecorator { | ||||
| func decorate( | ||||
| content: UNNotificationContent, | ||||
| sender: NotificationSenderInfo, | ||||
| api: HomeAssistantAPI | ||||
| ) -> Guarantee<UNNotificationContent> | ||||
| } | ||||
|
|
||||
| public final class NotificationCommunicationDecoratorImpl: NotificationCommunicationDecorator { | ||||
| private let cache: NotificationIconCache | ||||
|
|
||||
| public convenience init() { | ||||
| self.init(cache: NotificationIconCacheImpl()) | ||||
| } | ||||
|
|
||||
| init(cache: NotificationIconCache) { | ||||
| self.cache = cache | ||||
| } | ||||
|
|
||||
| public func decorate( | ||||
| content: UNNotificationContent, | ||||
| sender: NotificationSenderInfo, | ||||
| api: HomeAssistantAPI | ||||
| ) -> Guarantee<UNNotificationContent> { | ||||
| let title = content.title | ||||
| guard !title.isEmpty else { return .value(content) } | ||||
|
|
||||
| return buildIntent(sender: sender, title: title, body: content.body, api: api) | ||||
| .map { intent in | ||||
| do { | ||||
| return try content.updating(from: intent) | ||||
| } catch { | ||||
| Current.Log.error("Communication notification updating(from:) failed: \(error)") | ||||
| return content | ||||
| } | ||||
| } | ||||
| } | ||||
|
|
||||
| /// Internal so tests can drive it directly. Returns `Guarantee` because failures | ||||
| /// always fall back to a best-effort intent rather than rejecting the pipeline. | ||||
| func buildIntent( | ||||
| sender: NotificationSenderInfo, | ||||
| title: String, | ||||
| body: String, | ||||
| api: HomeAssistantAPI | ||||
| ) -> Guarantee<INSendMessageIntent> { | ||||
| avatarImage(for: sender.source, api: api).map { [self] image in | ||||
| let conversationID = conversationIdentifier(for: sender) | ||||
| let handle = INPersonHandle(value: conversationID, type: .unknown) | ||||
| var nameComponents = PersonNameComponents() | ||||
| nameComponents.nickname = title | ||||
| let person = INPerson( | ||||
| personHandle: handle, | ||||
| nameComponents: nameComponents, | ||||
| displayName: title, | ||||
| image: image, | ||||
| contactIdentifier: nil, | ||||
| customIdentifier: conversationID | ||||
| ) | ||||
| let intent = INSendMessageIntent( | ||||
| recipients: nil, | ||||
| outgoingMessageType: .outgoingMessageText, | ||||
| content: body, | ||||
| speakableGroupName: nil, | ||||
| conversationIdentifier: conversationID, | ||||
| serviceName: "HomeAssistant", | ||||
| sender: person, | ||||
| attachments: nil | ||||
| ) | ||||
| // Donate before returning so that `decorate`'s subsequent call to | ||||
| // `content.updating(from:)` can associate this notification with the | ||||
| // conversation. Donation is a global system side-effect (visible in Siri | ||||
| // suggestions); failures here are logged but never block notification | ||||
| // delivery, since the styling still applies without the donation. | ||||
| let interaction = INInteraction(intent: intent, response: nil) | ||||
| interaction.direction = .incoming | ||||
| interaction.donate { error in | ||||
| if let error { Current.Log.error("INInteraction donate failed: \(error)") } | ||||
| } | ||||
| return intent | ||||
| } | ||||
| } | ||||
|
|
||||
| /// MDI path is synchronous (no network). URL path downloads, caches, and downsamples the avatar. | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
| private func avatarImage( | ||||
| for source: NotificationSenderInfo.Source, | ||||
| api: HomeAssistantAPI | ||||
| ) -> Guarantee<INImage?> { | ||||
| switch source { | ||||
| case let .mdi(name, background, foreground, _, _): | ||||
| #if os(iOS) | ||||
| let image = INImage( | ||||
| icon: MaterialDesignIcons(serversideValueNamed: name, fallback: .bellIcon), | ||||
| foreground: foreground, | ||||
| background: background | ||||
| ) | ||||
| return .value(image) | ||||
| #else | ||||
| return .value(nil) | ||||
| #endif | ||||
| case let .iconURL(url, needsAuth): | ||||
| let cacheKey = notificationIconCacheKey(for: url) | ||||
| if let cached = cache.data(forKey: cacheKey) { | ||||
| return .value(INImage(imageData: cached)) | ||||
| } | ||||
|
bgoncal marked this conversation as resolved.
|
||||
| return Guarantee { seal in | ||||
| api.DownloadDataAt(url: url, needsAuth: needsAuth).done { [cache] downloadedFile in | ||||
| defer { | ||||
| try? FileManager.default.removeItem(at: downloadedFile) | ||||
| } | ||||
| guard let size = Self.fileSize(at: downloadedFile), size <= 5 * 1024 * 1024 else { | ||||
| Current.Log.error("Downloaded avatar file is too large or size unknown: \(downloadedFile.path)") | ||||
| seal(nil); return | ||||
| } | ||||
| guard let downsampled = Self.downsample(url: downloadedFile, maxDimension: 256) else { | ||||
| Current.Log.error("Failed to decode/downsample downloaded avatar from \(downloadedFile.path)") | ||||
| seal(nil); return | ||||
| } | ||||
| cache.setData(downsampled, forKey: cacheKey) | ||||
| seal(INImage(imageData: downsampled)) | ||||
| }.catch { error in | ||||
| Current.Log.error("Failed to download notification avatar from \(url): \(error)") | ||||
| seal(nil) | ||||
| } | ||||
| } | ||||
|
Roei-Bracha marked this conversation as resolved.
|
||||
| } | ||||
| } | ||||
|
|
||||
| /// Returns a stable, human-readable conversation identifier so iOS groups successive | ||||
| /// notifications from the same automation. Kept as a raw string (no hashing) so it can | ||||
| /// be eyeballed in logs and Siri suggestion dumps when diagnosing grouping issues. | ||||
| /// The `|` separator cannot appear in MDI names or 6-digit hex strings, so collisions | ||||
| /// across distinct inputs are not possible. | ||||
| private func conversationIdentifier(for sender: NotificationSenderInfo) -> String { | ||||
| let iconKey: String | ||||
| switch sender.source { | ||||
| case let .mdi(name, _, _, colorString, iconColorString): | ||||
| iconKey = "\(name)|\(colorString ?? "default")|\(iconColorString ?? "white")" | ||||
| case let .iconURL(url, _): | ||||
| iconKey = url.absoluteString | ||||
| } | ||||
| return "ha-sender:\(sender.senderName.lowercased()):\(iconKey)" | ||||
| } | ||||
|
Roei-Bracha marked this conversation as resolved.
|
||||
|
|
||||
| private static func fileSize(at url: URL) -> Int64? { | ||||
| let values = try? url.resourceValues(forKeys: [.fileSizeKey]) | ||||
| return values?.fileSize.map(Int64.init) | ||||
| } | ||||
|
|
||||
| /// Reduce the source image to at most `maxDimension` px on the longer side, returning | ||||
| /// fresh PNG bytes suitable for `INImage(imageData:)`. Returns `nil` if the image | ||||
| /// isn't a decodable format. | ||||
| /// | ||||
| /// ImageIO option choice (these operate at different levels): | ||||
| /// - `kCGImageSourceShouldCache: false` on the SOURCE: ImageIO must not cache the full | ||||
| /// decoded source pixels in its tile store. Critical for staying under the NSE's | ||||
| /// ~24 MB ceiling when the source happens to be a large JPEG/PNG. | ||||
| /// - `kCGImageSourceShouldCacheImmediately: true` on the THUMBNAIL: decode the small | ||||
| /// thumbnail eagerly rather than lazily, so the bitmap is realised here under our | ||||
| /// memory budget rather than later inside the Intents framework. | ||||
| /// | ||||
| /// `INImage` has no `init(cgImage:)`, so we round-trip back through PNG bytes. | ||||
| private static func downsample(url: URL, maxDimension: CGFloat) -> Data? { | ||||
| let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary | ||||
| guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { return nil } | ||||
| let downsampleOptions = [ | ||||
| kCGImageSourceCreateThumbnailFromImageAlways: true, | ||||
| kCGImageSourceShouldCacheImmediately: true, | ||||
| kCGImageSourceCreateThumbnailWithTransform: true, | ||||
| kCGImageSourceThumbnailMaxPixelSize: maxDimension, | ||||
| ] as CFDictionary | ||||
| guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { | ||||
| return nil | ||||
| } | ||||
| #if os(iOS) | ||||
| return UIImage(cgImage: thumbnail).pngData() | ||||
| #else | ||||
| return nil | ||||
| #endif | ||||
| } | ||||
| } | ||||
110 changes: 110 additions & 0 deletions
110
Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import CryptoKit | ||
| import Foundation | ||
|
|
||
| /// Disk-backed byte cache. | ||
| /// | ||
| /// Keys are opaque to the protocol; use `notificationIconCacheKey(for:)` to derive | ||
| /// the canonical key for a URL so different callers reach the same entry. | ||
| public protocol NotificationIconCache { | ||
| func data(forKey key: String) -> Data? | ||
| func setData(_ data: Data, forKey key: String) | ||
| } | ||
|
|
||
| /// Canonical cache key for a notification-icon URL. SHA-256 of the absolute URL | ||
| /// string with a `.img` suffix. Lives at module scope so callers don't need to | ||
| /// reference any concrete cache implementation. | ||
| public func notificationIconCacheKey(for url: URL) -> String { | ||
| let digest = SHA256.hash(data: Data(url.absoluteString.utf8)) | ||
| return digest.map { String(format: "%02x", $0) }.joined() + ".img" | ||
| } | ||
|
|
||
| public final class NotificationIconCacheImpl: NotificationIconCache { | ||
| private let directory: URL | ||
| private let maxEntries: Int | ||
| private let queue = DispatchQueue(label: "io.home-assistant.NotificationIconCache") | ||
| /// Cross-process coordinator: the App Group container is shared between the host app, | ||
| /// the Notification Service Extension, and Watch extensions. The serial queue alone | ||
| /// only guarantees ordering within this process. | ||
| private let coordinator = NSFileCoordinator() | ||
|
|
||
| public convenience init() { | ||
| let dir = AppConstants.AppGroupContainer | ||
| .appendingPathComponent("notification-icons", isDirectory: true) | ||
| self.init(directory: dir, maxEntries: 50) | ||
| } | ||
|
|
||
| init(directory: URL, maxEntries: Int) { | ||
| self.directory = directory | ||
| self.maxEntries = maxEntries | ||
| do { | ||
| try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) | ||
| } catch { | ||
| Current.Log.error("NotificationIconCache: failed to create directory \(directory.path): \(error)") | ||
| } | ||
| } | ||
|
|
||
| public func data(forKey key: String) -> Data? { | ||
| queue.sync { | ||
| let url = directory.appendingPathComponent(key) | ||
| var read: Data? | ||
| var coordinatorError: NSError? | ||
| coordinator.coordinate(readingItemAt: url, error: &coordinatorError) { coordinatedURL in | ||
| read = try? Data(contentsOf: coordinatedURL) | ||
| } | ||
| guard let data = read else { return nil } | ||
| // Best-effort mtime touch so LRU tracks reads as well as writes. | ||
| // Failure here only means this entry may be evicted slightly earlier — not a | ||
| // correctness concern, so no log. | ||
| try? FileManager.default.setAttributes( | ||
| [.modificationDate: Date()], | ||
| ofItemAtPath: url.path | ||
| ) | ||
| return data | ||
| } | ||
| } | ||
|
|
||
| public func setData(_ data: Data, forKey key: String) { | ||
| queue.sync { | ||
| let url = directory.appendingPathComponent(key) | ||
| var writeError: Error? | ||
| var coordinatorError: NSError? | ||
| coordinator | ||
| .coordinate(writingItemAt: url, options: .forReplacing, error: &coordinatorError) { coordinatedURL in | ||
| do { | ||
| try data.write(to: coordinatedURL, options: .atomic) | ||
| } catch { | ||
| writeError = error | ||
| } | ||
| } | ||
| if let error = (writeError ?? coordinatorError) { | ||
| Current.Log.error("NotificationIconCache: write failed for key \(key): \(error)") | ||
| } | ||
| prune() | ||
| } | ||
| } | ||
|
|
||
| /// Drop the oldest files until count <= maxEntries. Must be called on `queue`. | ||
| private func prune() { | ||
| guard let entries = try? FileManager.default.contentsOfDirectory( | ||
| at: directory, | ||
| includingPropertiesForKeys: [.contentModificationDateKey], | ||
| options: [.skipsHiddenFiles] | ||
| ) else { return } | ||
| guard entries.count > maxEntries else { return } | ||
|
|
||
| // Snapshot (url, mtime) once per entry, then sort — avoids re-stat'ing inside | ||
| // the comparator and keeps the prefetched cache from `contentsOfDirectory` the | ||
| // sole source of mtimes. | ||
| let dated = entries.map { url -> (URL, Date) in | ||
| let date = ( | ||
| try? url.resourceValues(forKeys: [.contentModificationDateKey]) | ||
| .contentModificationDate | ||
| ) ?? .distantPast | ||
| return (url, date) | ||
| } | ||
| let sorted = dated.sorted { $0.1 < $1.1 }.map(\.0) | ||
| for url in sorted.prefix(entries.count - maxEntries) { | ||
| try? FileManager.default.removeItem(at: url) | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use guard let here to improve readability