Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ source 'https://rubygems.org'
gem 'cocoapods'
gem 'cocoapods-acknowledgements'
gem 'fastlane', '2.222.0'
gem 'abbrev'
gem 'rubocop', require: false

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
Expand Down
4 changes: 3 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ GEM
base64
nkf
rexml
abbrev (0.1.2)
activesupport (7.1.3)
base64
bigdecimal
Expand Down Expand Up @@ -326,6 +327,7 @@ PLATFORMS
x86_64-darwin-23

DEPENDENCIES
abbrev
cocoapods
cocoapods-acknowledgements
fastlane (= 2.222.0)
Expand All @@ -335,4 +337,4 @@ DEPENDENCIES
rubocop

BUNDLED WITH
2.2.2
2.7.2
117 changes: 78 additions & 39 deletions HomeAssistant.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

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"
}
14 changes: 9 additions & 5 deletions Sources/Extensions/NotificationService/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,29 @@ final class NotificationService: UNNotificationServiceExtension {
) {
Current.Log.info("didReceive \(request), user info \(request.content.userInfo)")

guard let server = Current.servers.server(for: request.content), let api = Current.api(for: server) else {
guard let server = Current.servers.server(for: request.content),

Copy link
Copy Markdown
Member

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

let api = Current.api(for: server) else {
contentHandler(request.content)
return
}

firstly {
Current.notificationAttachmentManager.content(from: request.content, api: api)
}.recover { error in
}.recover { error -> Guarantee<UNNotificationContent> in
Current.Log.error("failed to get content, giving default: \(error)")
return .value(request.content)
}.then { content -> Guarantee<UNNotificationContent> in
guard let sender = NotificationSenderParser.parse(from: content) else {
return .value(content)
}
return Current.notificationCommunicationDecorator
.decorate(content: content, sender: sender, api: api)
}.done {
contentHandler($0)
}
}

override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content,
// otherwise the original push payload will be used.
Current.Log.warning("serviceExtensionTimeWillExpire")
}
}
2 changes: 2 additions & 0 deletions Sources/Shared/Environment/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ public class AppEnvironment {
public var updater = Updater()
public var serverAlerter = ServerAlerter()
public var notificationAttachmentManager: NotificationAttachmentManager = NotificationAttachmentManagerImpl()
public var notificationCommunicationDecorator: NotificationCommunicationDecorator =
Comment thread
Roei-Bracha marked this conversation as resolved.
Outdated
NotificationCommunicationDecoratorImpl()

/// Dispatchque local notifications (From the App to the App, not from Home Assistant)
public var notificationDispatcher: LocalNotificationDispatcherProtocol = LocalNotificationDispatcher()
Expand Down
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// MDI path is synchronous (no network). URL path downloads, caches, and downsamples the avatar.

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))
}
Comment thread
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)
}
}
Comment thread
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)"
}
Comment thread
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
}
}
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)
}
}
}
Loading
Loading