Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions Configuration/Entitlements/Extension-catalyst.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
</array>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).HomeAssistant$(BUNDLE_ID_SUFFIX)</string>
Expand Down
2 changes: 2 additions & 0 deletions Configuration/Entitlements/Extension-ios.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<array>
<string>group.$(BUNDLE_ID_PREFIX).homeassistant$(BUNDLE_ID_SUFFIX)</string>
</array>
<key>com.apple.developer.usernotifications.communication</key>
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.

In theory Apple can reject that because this was meant for messaging apps, have you seen non-messaging apps using it? I'm not against trying to get approved, just a heads up

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I looked into this and I agree there is some App Review risk here. Apple’s docs frame Communication Notifications around direct calls/messages and sender participants, not generic app notifications.

I found some App Store apps outside pure messaging that use this, for example Spooky AI uses Communication Notifications for agent profile pictures, and Artemis Learning uses them for course conversations. But both still frame the notification as communication/conversation-like, so they are not a perfect guarantee for Home Assistant automation notifications.

I’m okay trying this if we’re comfortable with that risk.

<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).HomeAssistant$(BUNDLE_ID_SUFFIX)</string>
Expand Down
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"
}
24 changes: 18 additions & 6 deletions Sources/Extensions/NotificationService/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,43 @@ import Shared
import UserNotifications

final class NotificationService: UNNotificationServiceExtension {
private let notificationCommunicationDecorator = NotificationCommunicationDecoratorImpl()

override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
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 {
contentHandler(request.content)
guard let server = Current.servers.server(for: request.content),
let api = Current.api(for: server) else {
if let sender = NotificationSenderParser.parse(from: request.content) {
notificationCommunicationDecorator
.decorate(content: request.content, sender: sender, api: nil)
.done { contentHandler($0) }
} 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 self.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")
}
}
11 changes: 11 additions & 0 deletions Sources/Extensions/NotificationService/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,19 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser {
addAttachment(key: "image", contentType: "jpeg")
addAttachment(key: "audio", contentType: "waveformaudio")

for key in NotificationDecorationPayloadKey.notificationDecorationKeys {
if let value = data[key.rawValue] {
payload[key.rawValue] = value
}
}
if payload[NotificationDecorationPayloadKey.iconURL.rawValue] != nil ||
payload[NotificationDecorationPayloadKey.notificationIcon.rawValue] != nil {
needsMutableContent = true
}

payload["url"] = data["url"]
payload["shortcut"] = data["shortcut"]
payload["presentation_options"] = data["presentation_options"]
Expand Down Expand Up @@ -257,6 +267,20 @@ enum LegacyNotificationCommandType: String {
case updateWidgets = "update_widgets"
}

enum NotificationDecorationPayloadKey: String, CaseIterable {
case iconURL = "icon_url"
case notificationIcon = "notification_icon"
case notificationIconColor = "notification_icon_color"
case color

static let notificationDecorationKeys: [Self] = [
.iconURL,
.notificationIcon,
.notificationIconColor,
.color,
]
}

private extension Dictionary where Value == Any {
mutating func mutate<SomeValue>(
_ key: Key,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
import SharedPush
@testable import SharedPush
import XCTest

class NotificationParserLegacyTests: XCTestCase {
Expand Down Expand Up @@ -65,4 +65,11 @@ class NotificationParserLegacyTests: XCTestCase {
XCTAssertEqual(resultString, expectedString, data.name)
}
}

func testNotificationDecorationKeyRawValues() {
XCTAssertEqual(NotificationDecorationPayloadKey.iconURL.rawValue, "icon_url")
XCTAssertEqual(NotificationDecorationPayloadKey.notificationIcon.rawValue, "notification_icon")
XCTAssertEqual(NotificationDecorationPayloadKey.notificationIconColor.rawValue, "notification_icon_color")
XCTAssertEqual(NotificationDecorationPayloadKey.color.rawValue, "color")
}
}
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.

Is this used for unit tests or just left behind?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, this fixture is used. NotificationParserLegacyTests.testAllCases iterates all JSON files in notification_test_cases.bundle, including notification_icon.json.

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"input": {
"message": "test",
"title": "Phone",
"data": {
"notification_icon": "mdi:cellphone",
"notification_icon_color": "#FFFFFF",
"color": "#03A9F4"
},
"registration_info": {
"app_id": "io.robbie.HomeAssistant.dev",
"os_version": "10.15",
"app_version": "2021.5"
}
},
"rate_limit": true,
"headers": {
"apns-push-type": "alert"
},
"payload": {
"aps": {
"alert": {
"body": "test",
"title": "Phone"
},
"mutable-content": true,
"sound": "default"
},
"color": "#03A9F4",
"notification_icon": "mdi:cellphone",
"notification_icon_color": "#FFFFFF"
}
}
14 changes: 13 additions & 1 deletion Sources/Shared/Notifications/LocalPush/LocalPushManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public protocol LocalPushManagerDelegate: AnyObject {
public class LocalPushManager {
public let server: Server
public weak var delegate: LocalPushManagerDelegate?
private let notificationCommunicationDecorator: NotificationCommunicationDecorator

public static let stateDidChange: Notification.Name = .init(rawValue: "LocalPushManagerStateDidChange")

Expand Down Expand Up @@ -82,8 +83,13 @@ public class LocalPushManager {

private var tokens = [HACancellable]()

public init(server: Server) {
public init(
server: Server,
notificationCommunicationDecorator: NotificationCommunicationDecorator =
NotificationCommunicationDecoratorImpl()
) {
self.server = server
self.notificationCommunicationDecorator = notificationCommunicationDecorator

updateSubscription()
tokens.append(server.observe { [weak self] _ in
Expand Down Expand Up @@ -181,6 +187,12 @@ public class LocalPushManager {
}.recover { error in
Current.Log.error("failed to get content, giving default: \(error)")
return .value(baseContent)
}.then { content -> Guarantee<UNNotificationContent> in
if let sender = NotificationSenderParser.parse(from: content) {
return self.notificationCommunicationDecorator.decorate(content: content, sender: sender, api: api)
} else {
return .value(content)
}
}.then { [add] content -> Promise<Void> in
add(UNNotificationRequest(identifier: event.identifier, content: content, trigger: nil))
}.then { [subscription] () -> Promise<Void> in
Expand Down
Loading