diff --git a/Configuration/Entitlements/Extension-catalyst.entitlements b/Configuration/Entitlements/Extension-catalyst.entitlements
index 1c5ab6282e..44233c8274 100644
--- a/Configuration/Entitlements/Extension-catalyst.entitlements
+++ b/Configuration/Entitlements/Extension-catalyst.entitlements
@@ -10,6 +10,8 @@
com.apple.security.network.client
+ com.apple.developer.usernotifications.communication
+
keychain-access-groups
$(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).HomeAssistant$(BUNDLE_ID_SUFFIX)
diff --git a/Configuration/Entitlements/Extension-ios.entitlements b/Configuration/Entitlements/Extension-ios.entitlements
index 5f83cb6ef4..5f765962b0 100644
--- a/Configuration/Entitlements/Extension-ios.entitlements
+++ b/Configuration/Entitlements/Extension-ios.entitlements
@@ -8,6 +8,8 @@
group.$(BUNDLE_ID_PREFIX).homeassistant$(BUNDLE_ID_SUFFIX)
+ com.apple.developer.usernotifications.communication
+
keychain-access-groups
$(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).HomeAssistant$(BUNDLE_ID_SUFFIX)
diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj
index a5641444eb..e8cab52a1a 100644
--- a/HomeAssistant.xcodeproj/project.pbxproj
+++ b/HomeAssistant.xcodeproj/project.pbxproj
@@ -25,7 +25,9 @@
/* Begin PBXBuildFile section */
00C267608DCC88B783816CBF /* FanIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6888525DCF492642BA7EA3 /* FanIntent.swift */; };
01783C74005B40978CE7508B /* NotificationIdentifierField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99EC7EF1136575D0E7A17091 /* NotificationIdentifierField.swift */; };
+ 049D93E73DA5C3F4DDCAE78B /* NotificationSenderInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BAFAF531E869C27ED7EED78 /* NotificationSenderInfo.swift */; };
072BACCC5B2509E4AF06BFED /* KioskSettingsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14444A34DA125693568C7035 /* KioskSettingsTable.swift */; };
+ 0ABDE87C08B601388030C246 /* NotificationSenderParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDA89DB692DBB2B15C6582F /* NotificationSenderParser.swift */; };
0C1F82B6DF9051F98A587352 /* ComplicationEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3995EF7323087AA35F01BDDF /* ComplicationEditView.swift */; };
0907D6244565287ED4E46A3C /* WhatsNewCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09953104F4749B10268B7C61 /* WhatsNewCatalog.swift */; };
5002BDAF2FEE000100BEEF01 /* WhatsNewEngine.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5002BDAE2FEE000100BEEF01 /* WhatsNewEngine.test.swift */; };
@@ -468,6 +470,8 @@
2F50FC61669812D485E608EC /* Pods-iOS-Extensions-PushProvider-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */; };
324A8B0F152A80EA190D98F3 /* Pods_iOS_Extensions_Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC9B77AAC44845DC9EE48759 /* Pods_iOS_Extensions_Intents.framework */; };
37414CBA81F74D6AADCFE442 /* WidgetCommonlyUsedEntitiesTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF91E383A44843F087423FB5 /* WidgetCommonlyUsedEntitiesTimelineProvider.swift */; };
+ 37F03845BDFCD4EC98483F13 /* NotificationSenderInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BAFAF531E869C27ED7EED78 /* NotificationSenderInfo.swift */; };
+ 38563FD894F79F24705C5455 /* NotificationIconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49AED9BCF91ADD4EBBC025D4 /* NotificationIconCache.swift */; };
38A4EBA18ADEEE555AD14F52 /* Pods-iOS-App-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */; };
3997926A2B7F904A00231B54 /* MobileAppConfigPushCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792692B7F904A00231B54 /* MobileAppConfigPushCategory.swift */; };
3997926B2B7F904A00231B54 /* MobileAppConfigPushCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792692B7F904A00231B54 /* MobileAppConfigPushCategory.swift */; };
@@ -957,7 +961,6 @@
42A47A8A2C452DB500C9B43D /* MockWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A47A892C452DB500C9B43D /* MockWebViewController.swift */; };
42A47A902C4548E100C9B43D /* ImprovDiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A47A8F2C4548E100C9B43D /* ImprovDiscoverView.swift */; };
42A7F1012FBC000100BEEF01 /* AllowedTag.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1002FBC000100BEEF01 /* AllowedTag.test.swift */; };
- 42A7F1012FBC000100BEEF01 /* Database/AllowedTag.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1002FBC000100BEEF01 /* Database/AllowedTag.test.swift */; };
42A7F1212FBC000100BEEF01 /* NFCTagApproval.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1202FBC000100BEEF01 /* NFCTagApproval.test.swift */; };
42A818E02BBEA8150083D045 /* AssistViewModel.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A818DF2BBEA8150083D045 /* AssistViewModel.test.swift */; };
42A818E32BBEA9780083D045 /* MockAudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A818E22BBEA9780083D045 /* MockAudioRecorder.swift */; };
@@ -1255,10 +1258,11 @@
5F2ECF3C2CA505A59A1CFFAF /* Pods_iOS_SharedTesting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65B4DC669522BB7A70C5EED0 /* Pods_iOS_SharedTesting.framework */; };
61495A70232316478717CF27 /* Pods_iOS_Shared_iOS_Tests_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FF3A67FB1C2B548C6C7730C /* Pods_iOS_Shared_iOS_Tests_Shared.framework */; };
618FCE5CA6B34267BB2056F5 /* MockLiveActivityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */; };
+ 647725908C85F30DDEBB76B8 /* NotificationSenderParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDA89DB692DBB2B15C6582F /* NotificationSenderParser.swift */; };
651755E378F6F79AB401F05C /* AssistPipelineAddList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07701F2786F6D45E945CC1AA /* AssistPipelineAddList.swift */; };
65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; };
6596FA74E1A501276EA62D86 /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD370D44DFFB906B05C3EB3A /* Pods_watchOS_Shared_watchOS.framework */; };
- 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */; };
+ 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */; };
6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; };
70BD8A8EA1ABC5DC1F0A0D6E /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C663B0750E0318469E7008C3 /* Pods_iOS_Shared_iOS.framework */; };
71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */; };
@@ -1266,14 +1270,17 @@
84F7755EFB03C3F463292ABF /* Pods-watchOS-Shared-watchOS-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */; };
864FFB19D2954B698B31E350 /* WebViewController+ReAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1945E8E386664E5A88668E77 /* WebViewController+ReAuth.swift */; };
87ADC61008345747CABC2270 /* KioskSettingsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14444A34DA125693568C7035 /* KioskSettingsTable.swift */; };
+ 8933A8C4AF839B6124528F4E /* NotificationCommunicationDecoratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6489140FB77123E7A3208A49 /* NotificationCommunicationDecoratorTests.swift */; };
897D7538631C46D4BD849CF5 /* NotificationsCommandManagerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */; };
8DFA3DEE4881E59961C3B5E2 /* BarometerSensor.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A4CD40BADC119045547D77 /* BarometerSensor.test.swift */; };
8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; };
+ 912FB683381E0CF42037596E /* NotificationCommunicationDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3EF62803035779DE2513F2 /* NotificationCommunicationDecorator.swift */; };
925D92FC9E2221189D67B22C /* ComplicationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FF9C3A30E10A8E214623EBB /* ComplicationListView.swift */; };
+ 97271CFAB2E8E4010631DB25 /* NotificationCommunicationDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3EF62803035779DE2513F2 /* NotificationCommunicationDecorator.swift */; };
999549244371450BC98C700E /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */; };
9D57ECBD5431BC00BDC16F1E /* NotificationActionEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F913E441276235B7A2D7B29 /* NotificationActionEditorView.swift */; };
A1619F1ED93FB8B0E7E53C38 /* KioskLifecycleBrightness.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373AE72CB925F044BAE18B62 /* KioskLifecycleBrightness.test.swift */; };
- A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; };
+ A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */; };
A596C4D1E125E6863C7D2034 /* ComplicationEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512A72C5F1BCC979E74F7629 /* ComplicationEditViewModel.swift */; };
A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; };
A60E917B401A6D456F1DB630 /* ComplicationFamilySelectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1861EB0361816DC9260D1F5E /* ComplicationFamilySelectView.swift */; };
@@ -1537,9 +1544,11 @@
B6DDF8534A4176416CFAC79A /* KioskSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49767602CA2066683EC638F /* KioskSettingsView.swift */; };
B6E2D4D52270706300446DFA /* ha-loading.json in Resources */ = {isa = PBXBuildFile; fileRef = B6E2D4D42270706200446DFA /* ha-loading.json */; };
B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; };
+ BA1AE9960FDBD2197E7F72C5 /* NotificationIconCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7303AC356A2589F690B6CC /* NotificationIconCacheTests.swift */; };
BB77559927344584B2C0E987 /* OnboardingAuthError.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */; };
+ BCBFB6DBB37D6C1036FB7C4D /* NotificationSenderInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32795036FA9992877C8BFF76 /* NotificationSenderInfoTests.swift */; };
BD1044995DE13A04C0FA039A /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9C81015FD7A8FA8716E4F2 /* Pods_iOS_Extensions_Widgets.framework */; };
- BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */; };
+ BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */; };
C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; };
C1AE883A374C598B5BCCAE23 /* CustomWidgetIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7FF77B68D19A4AD68F8FE3 /* CustomWidgetIntentHelper.swift */; };
C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; };
@@ -1549,6 +1558,7 @@
CA0CA15000000000000000A2 /* LocationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0CA15000000000000000A1 /* LocationSettingsView.swift */; };
CA0CA15000000000000000B2 /* LocationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0CA15000000000000000B1 /* LocationSettingsViewModel.swift */; };
CA6886D02384DA18A91F37DD /* Pods-iOS-Extensions-Intents-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */; };
+ CB45D5CABCCA75A0DCB9047B /* NotificationIconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49AED9BCF91ADD4EBBC025D4 /* NotificationIconCache.swift */; };
CB4D44CC6DBA5176155E157E /* KioskSecretExitGestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE1C6F8FA2181C936758465 /* KioskSecretExitGestureView.swift */; };
D014EEA92128E192008EA6F5 /* ConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D014EEA82128E192008EA6F5 /* ConnectionInfo.swift */; };
D03D892920E0A85300D4F28D /* Shared.h in Headers */ = {isa = PBXBuildFile; fileRef = D03D891920E0A85300D4F28D /* Shared.h */; settings = {ATTRIBUTES = (Public, ); }; };
@@ -1594,10 +1604,10 @@
D9A6697AF4D05BB8DE822A54 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */; };
D9BF1EFF40733A4A1D03B9C8 /* CustomWidgetIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7FF77B68D19A4AD68F8FE3 /* CustomWidgetIntentHelper.swift */; };
DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */; };
- DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */; };
DB54626ADCE0C32094C8C0B9 /* LoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */; };
DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */; };
E3A02409794174F002C8BB4F /* IconSearchPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC23DE131CA8813C2DBD657 /* IconSearchPicker.swift */; };
+ E7060E233474B57828DA3BBC /* NotificationSenderParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6E232706807425E1E56720 /* NotificationSenderParserTests.swift */; };
E92E09E3A93650D56E3C5093 /* KioskScreensaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */; };
F0D1DD41A8F55F6D767EBF37 /* TemplatePreviewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332C060487B468055C487070 /* TemplatePreviewSection.swift */; };
FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; };
@@ -2261,7 +2271,9 @@
273BF4150C522259C6183B32 /* Pods-watchOS-Shared-watchOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.debug.xcconfig"; sourceTree = ""; };
2A674A3ACB31BFCEB70423FE /* Pods-watchOS-WatchExtension-Watch.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.release.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.release.xcconfig"; sourceTree = ""; };
2C50B9249EE482AF53672D36 /* Pods-iOS-SharedTesting.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-SharedTesting.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-SharedTesting/Pods-iOS-SharedTesting.beta.xcconfig"; sourceTree = ""; };
+ 2C7303AC356A2589F690B6CC /* NotificationIconCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationIconCacheTests.swift; sourceTree = ""; };
2F5A1F2C65420338C176B7BD /* CameraZoomGestureOverlay.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CameraZoomGestureOverlay.swift; sourceTree = ""; };
+ 32795036FA9992877C8BFF76 /* NotificationSenderInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSenderInfoTests.swift; sourceTree = ""; };
332C060487B468055C487070 /* TemplatePreviewSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TemplatePreviewSection.swift; sourceTree = ""; };
33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Share.framework; sourceTree = BUILT_PRODUCTS_DIR; };
365F0937BF33147F29CE8F8B /* Pods-watchOS-Shared-watchOS.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.beta.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.beta.xcconfig"; sourceTree = ""; };
@@ -2849,7 +2861,6 @@
42A7474A2E832FB8005E0332 /* Snapfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Snapfile; sourceTree = ""; };
42A7474B2E832FB8005E0332 /* SnapshotHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; };
42A7F1002FBC000100BEEF01 /* AllowedTag.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/AllowedTag.test.swift; sourceTree = ""; };
- 42A7F1002FBC000100BEEF01 /* Database/AllowedTag.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/AllowedTag.test.swift; sourceTree = ""; };
42A7F1202FBC000100BEEF01 /* NFCTagApproval.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCTagApproval.test.swift; sourceTree = ""; };
42A818DF2BBEA8150083D045 /* AssistViewModel.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistViewModel.test.swift; sourceTree = ""; };
42A818E22BBEA9780083D045 /* MockAudioRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAudioRecorder.swift; sourceTree = ""; };
@@ -3090,8 +3101,10 @@
46F9A1002F0A000100ABCD01 /* StatusItemTitleRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItemTitleRendererTests.swift; sourceTree = ""; };
480E9A5D40714BBAA81B15F7 /* ClientCertificate.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificate.test.swift; sourceTree = ""; };
491E98FE25D543560077BBE3 /* LogbookEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbookEntry.swift; sourceTree = ""; };
+ 49AED9BCF91ADD4EBBC025D4 /* NotificationIconCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationIconCache.swift; sourceTree = ""; };
49BCC46A2F929005F9529BF9 /* Pods-watchOS-WatchExtension-Watch.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.beta.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.beta.xcconfig"; sourceTree = ""; };
4A74A2EF6714F9B13C1DAFBD /* Pods-Tests-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.debug.xcconfig"; sourceTree = ""; };
+ 4D6E232706807425E1E56720 /* NotificationSenderParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationSenderParserTests.swift; sourceTree = ""; };
4F28982D819D8E105E6FB878 /* Pods-Tests-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.release.xcconfig"; sourceTree = ""; };
4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskLocalization.test.swift; sourceTree = ""; };
504A2C852F8133E6002A3C0E /* CarPlayTabsSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayTabsSelectionView.swift; sourceTree = ""; };
@@ -3100,17 +3113,19 @@
553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-App-metadata.plist"; path = "Pods/Pods-iOS-App-metadata.plist"; sourceTree = ""; };
592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; sourceTree = ""; };
5BFD0D30836C69840AB63A8A /* LoadingButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoadingButton.swift; sourceTree = ""; };
- 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; };
+ 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; };
5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = ""; };
5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLiveActivityRegistry.swift; sourceTree = ""; };
5FF9C3A30E10A8E214623EBB /* ComplicationListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListView.swift; sourceTree = ""; };
608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_PushProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; };
62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskScreensaverViewController.swift; sourceTree = ""; };
+ 6489140FB77123E7A3208A49 /* NotificationCommunicationDecoratorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCommunicationDecoratorTests.swift; sourceTree = ""; };
6563AFB7BDAF57478CA18D9B /* Pods-iOS-Extensions-PushProvider.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.debug.xcconfig"; sourceTree = ""; };
65B4DC669522BB7A70C5EED0 /* Pods_iOS_SharedTesting.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_SharedTesting.framework; sourceTree = BUILT_PRODUCTS_DIR; };
6723A4E97E50C3C9141428D0 /* Pods-iOS-Extensions-Widgets-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Widgets-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Widgets-metadata.plist"; sourceTree = ""; };
6A7FF77B68D19A4AD68F8FE3 /* CustomWidgetIntentHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CustomWidgetIntentHelper.swift; sourceTree = ""; };
6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-Shared-watchOS-metadata.plist"; path = "Pods/Pods-watchOS-Shared-watchOS-metadata.plist"; sourceTree = ""; };
+ 6BAFAF531E869C27ED7EED78 /* NotificationSenderInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSenderInfo.swift; sourceTree = ""; };
6BC23DE131CA8813C2DBD657 /* IconSearchPicker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IconSearchPicker.swift; sourceTree = ""; };
6BECEB2525564358A124F818 /* FolderEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderEditView.swift; sourceTree = ""; };
6D00E1755885575FF8118933 /* ControlFan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFan.swift; sourceTree = ""; };
@@ -3122,10 +3137,11 @@
7AFA80D48C707A822CCB3F38 /* Pods-iOS-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.release.xcconfig"; sourceTree = ""; };
7C3CCF89D04DB409DDFC4A09 /* Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig"; sourceTree = ""; };
7DC07BDAC69AD95BDEFD8AFF /* Pods-iOS-Extensions-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.release.xcconfig"; sourceTree = ""; };
+ 7DDA89DB692DBB2B15C6582F /* NotificationSenderParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationSenderParser.swift; sourceTree = ""; };
825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = ""; };
862436CFE6E3F4B31500EFB2 /* ComplicationListViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListViewModel.swift; sourceTree = ""; };
86BFD63671D2D0A012DFE169 /* Pods-iOS-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.debug.xcconfig"; sourceTree = ""; };
- 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; };
+ 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; };
8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFanValueProvider.swift; sourceTree = ""; };
8D6888525DCF492642BA7EA3 /* FanIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanIntent.swift; sourceTree = ""; };
9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-WatchExtension-Watch-metadata.plist"; path = "Pods/Pods-watchOS-WatchExtension-Watch-metadata.plist"; sourceTree = ""; };
@@ -3138,7 +3154,7 @@
9C4E5E27229D992A0044C8EC /* HomeAssistant.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.xcconfig; sourceTree = ""; };
9D84964A844E6CD21F16D3AB /* Pods-watchOS-WatchExtension-Watch.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; sourceTree = ""; };
9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFolderRow.swift; sourceTree = ""; };
- 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; };
+ 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; };
9F913E441276235B7A2D7B29 /* NotificationActionEditorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationActionEditorView.swift; sourceTree = ""; };
9F9398CFD66E4C66DC39E1D3 /* Pods-iOS-Extensions-PushProvider.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.beta.xcconfig"; sourceTree = ""; };
A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthError.test.swift; sourceTree = ""; };
@@ -3149,6 +3165,7 @@
A95FDD112F6B8A3E008EF72F /* HALiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALiveActivityConfiguration.swift; sourceTree = ""; };
A95FDD122F6B8A3E008EF72F /* HALockScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALockScreenView.swift; sourceTree = ""; };
A95FDD172F6B8A5B008EF72F /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; };
+ AA3EF62803035779DE2513F2 /* NotificationCommunicationDecorator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCommunicationDecorator.swift; sourceTree = ""; };
AA48C686F844D08C426A8D74 /* Pods_Tests_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Tests_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AC24B1CAB85767B8171BB850 /* Pods-iOS-Extensions-NotificationContent.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationContent.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationContent/Pods-iOS-Extensions-NotificationContent.release.xcconfig"; sourceTree = ""; };
AC720001000000000000AA01 /* ActionsSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionsSettingsView.swift; sourceTree = ""; };
@@ -3468,7 +3485,7 @@
B6FD0574228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; };
B7D8DAEFAD435091FDDD61E7 /* Pods_iOS_Extensions_NotificationContent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationContent.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B833A17275EC47FA65A3235A /* YamlPreviewSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = YamlPreviewSection.swift; sourceTree = ""; };
- BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; };
+ BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; };
BC9B77AAC44845DC9EE48759 /* Pods_iOS_Extensions_Intents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Intents.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BDC6ACBDCC2C47510C37E4C8 /* NotificationCategoryListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCategoryListView.swift; sourceTree = ""; };
BEF9A7008EFA4A6FC9E02B5E /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; };
@@ -3517,6 +3534,7 @@
D72C761F65606EF882E2A7B1 /* Pods-iOS-Extensions-Today-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Today-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Today-metadata.plist"; sourceTree = ""; };
D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCommandManagerLiveActivityTests.swift; sourceTree = ""; };
DEEEA3344F064DB183F46C47 /* WidgetCommonlyUsedEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommonlyUsedEntities.swift; sourceTree = ""; };
+ E1AB8533E2EF222138FF0E38 /* communication_notification.apns */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = communication_notification.apns; sourceTree = ""; };
E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-PushProvider-metadata.plist"; path = "Pods/Pods-iOS-Extensions-PushProvider-metadata.plist"; sourceTree = ""; };
E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Intents-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Intents-metadata.plist"; sourceTree = ""; };
ED4B2D38DF1316D881D79769 /* Pods-iOS-Shared-iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.debug.xcconfig"; sourceTree = ""; };
@@ -3743,6 +3761,7 @@
110FB4512499DB3A000865B4 /* map_notification.apns */,
1100D52024974D6700B1073C /* camera_notification.apns */,
11169B3E262BCEE6005EF90A /* dynamic_notification.apns */,
+ E1AB8533E2EF222138FF0E38 /* communication_notification.apns */,
);
path = TestNotifications;
sourceTree = "";
@@ -6531,6 +6550,17 @@
path = ClientEvents;
sourceTree = "";
};
+ 4926A2CC8C41FFA25A4AEFED /* NotificationSender */ = {
+ isa = PBXGroup;
+ children = (
+ 6BAFAF531E869C27ED7EED78 /* NotificationSenderInfo.swift */,
+ 7DDA89DB692DBB2B15C6582F /* NotificationSenderParser.swift */,
+ 49AED9BCF91ADD4EBBC025D4 /* NotificationIconCache.swift */,
+ AA3EF62803035779DE2513F2 /* NotificationCommunicationDecorator.swift */,
+ );
+ path = NotificationSender;
+ sourceTree = "";
+ };
4C1A049B16335C08AECDAAC2 /* Screensaver */ = {
isa = PBXGroup;
children = (
@@ -7206,6 +7236,17 @@
path = Responses;
sourceTree = "";
};
+ B785E5717AD9D00AF1F15A2C /* NotificationSender */ = {
+ isa = PBXGroup;
+ children = (
+ 32795036FA9992877C8BFF76 /* NotificationSenderInfoTests.swift */,
+ 4D6E232706807425E1E56720 /* NotificationSenderParserTests.swift */,
+ 2C7303AC356A2589F690B6CC /* NotificationIconCacheTests.swift */,
+ 6489140FB77123E7A3208A49 /* NotificationCommunicationDecoratorTests.swift */,
+ );
+ path = NotificationSender;
+ sourceTree = "";
+ };
CA0CA15000000000000000C1 /* Location */ = {
isa = PBXGroup;
children = (
@@ -7358,15 +7399,16 @@
11CB98CC249E637300B05222 /* Version+HA.test.swift */,
11883CC424C12C8A0036A6C6 /* CLLocation+Extensions.test.swift */,
11883CC624C131EE0036A6C6 /* RealmZone.test.swift */,
- 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */,
- 42A7F1002FBC000100BEEF01 /* Database/AllowedTag.test.swift */,
- BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */,
- 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */,
- 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */,
+ 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */,
+ 42A7F1002FBC000100BEEF01 /* AllowedTag.test.swift */,
+ BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */,
+ 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */,
+ 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */,
11EE9B4B24C5181A00404AF8 /* ModelManager.test.swift */,
11BC9E5424FDB88200B9FBF7 /* ActiveStateManager.test.swift */,
1104FCCE253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift */,
11F2F28D2587285300F61F7C /* NotificationAttachment */,
+ B785E5717AD9D00AF1F15A2C /* NotificationSender */,
110AA57A25B38C02005061A0 /* ServerAlerter.test.swift */,
11267D0825BBA9FE00F28E5C /* Updater.test.swift */,
1133F5E425F1DBEA00AD776F /* CLLocation+Sanitize.test.swift */,
@@ -7522,6 +7564,7 @@
11ADF93D267D34A20040A7E3 /* NotificationCommands */,
11A3BD2B261921FC005237E6 /* LocalPush */,
11F2F21725871C1700F61F7C /* NotificationAttachments */,
+ 4926A2CC8C41FFA25A4AEFED /* NotificationSender */,
11169B9A262BE3E1005EF90A /* UNNotificationContent+Additions.swift */,
);
path = Notifications;
@@ -8250,7 +8293,7 @@
packageReferences = (
420E64BB2D676B2400A31E86 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */,
42B89EA62E05CC54000224A2 /* XCRemoteSwiftPackageReference "WebRTC" */,
- 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */,
+ 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */,
4237E6372E5333370023B673 /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
42B18FD52F38CA2300A1537A /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
42F384032FB49C9500390AFC /* XCRemoteSwiftPackageReference "DebugSwift" */,
@@ -8779,14 +8822,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks.sh\"\n";
@@ -8924,14 +8963,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks.sh\"\n";
@@ -8967,14 +9002,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks.sh\"\n";
@@ -9074,14 +9105,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks.sh\"\n";
@@ -10149,6 +10176,7 @@
11AF4D20249C8AF1006C74C0 /* ConnectivitySensor.swift in Sources */,
42C0F7CA2D47936100BD5C76 /* StatePrecision.swift in Sources */,
11F2F27F258725D300F61F7C /* NotificationAttachmentErrorImage.swift in Sources */,
+ 37F03845BDFCD4EC98483F13 /* NotificationSenderInfo.swift in Sources */,
B67CE8A622200F220034C1D0 /* HAAPI.swift in Sources */,
42B2637E2E16A1DC0042DF10 /* BaseColors.swift in Sources */,
1105CE1D272B9CB300F33BD8 /* ServerManager.swift in Sources */,
@@ -10159,6 +10187,9 @@
1104FC9225322C1800B8BE34 /* Dictionary+Additions.swift in Sources */,
118261F824F8D6B0000795C6 /* SensorProviderDependencies.swift in Sources */,
11C8E8AD24F36535003E7F89 /* DeviceWrapper.swift in Sources */,
+ 647725908C85F30DDEBB76B8 /* NotificationSenderParser.swift in Sources */,
+ CB45D5CABCCA75A0DCB9047B /* NotificationIconCache.swift in Sources */,
+ 912FB683381E0CF42037596E /* NotificationCommunicationDecorator.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -10518,6 +10549,10 @@
118261F724F8D6B0000795C6 /* SensorProviderDependencies.swift in Sources */,
11C8E8AE24F3778E003E7F89 /* DeviceWrapper.swift in Sources */,
87ADC61008345747CABC2270 /* KioskSettingsTable.swift in Sources */,
+ 049D93E73DA5C3F4DDCAE78B /* NotificationSenderInfo.swift in Sources */,
+ 0ABDE87C08B601388030C246 /* NotificationSenderParser.swift in Sources */,
+ 38563FD894F79F24705C5455 /* NotificationIconCache.swift in Sources */,
+ 97271CFAB2E8E4010631DB25 /* NotificationCommunicationDecorator.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -10536,12 +10571,13 @@
11EE9B4C24C5181A00404AF8 /* ModelManager.test.swift in Sources */,
11AF4D2C249D965C006C74C0 /* BatterySensor.test.swift in Sources */,
11F2F2B8258728B200F61F7C /* NotificationAttachmentParserURL.test.swift in Sources */,
+ BCBFB6DBB37D6C1036FB7C4D /* NotificationSenderInfoTests.swift in Sources */,
11883CC724C131EE0036A6C6 /* RealmZone.test.swift in Sources */,
- DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */,
- 42A7F1012FBC000100BEEF01 /* Database/AllowedTag.test.swift in Sources */,
- A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */,
- 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */,
- BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */,
+ DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */,
+ 42A7F1012FBC000100BEEF01 /* AllowedTag.test.swift in Sources */,
+ A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */,
+ 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */,
+ BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */,
11267D0925BBA9FE00F28E5C /* Updater.test.swift in Sources */,
11A3F08C24ECE88C0018D84F /* WebhookUpdateLocation.test.swift in Sources */,
42FDCA272F0C7EB900C92958 /* EntityRegistry.test.swift in Sources */,
@@ -10590,6 +10626,9 @@
618FCE5CA6B34267BB2056F5 /* MockLiveActivityRegistry.swift in Sources */,
71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */,
897D7538631C46D4BD849CF5 /* NotificationsCommandManagerLiveActivityTests.swift in Sources */,
+ E7060E233474B57828DA3BBC /* NotificationSenderParserTests.swift in Sources */,
+ BA1AE9960FDBD2197E7F72C5 /* NotificationIconCacheTests.swift in Sources */,
+ 8933A8C4AF839B6124528F4E /* NotificationCommunicationDecoratorTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -12423,7 +12462,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
- 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = {
+ 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Sources/SharedPush;
};
@@ -12495,7 +12534,7 @@
};
4273F7DF2E258827000629F7 /* SharedPush */ = {
isa = XCSwiftPackageProductDependency;
- package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */;
+ package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */;
productName = SharedPush;
};
427692E22B98B82500F24321 /* SharedPush */ = {
diff --git a/Sources/Extensions/NotificationContent/Resources/TestNotifications/communication_notification.apns b/Sources/Extensions/NotificationContent/Resources/TestNotifications/communication_notification.apns
new file mode 100644
index 0000000000..316e40d8da
--- /dev/null
+++ b/Sources/Extensions/NotificationContent/Resources/TestNotifications/communication_notification.apns
@@ -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"
+}
diff --git a/Sources/Extensions/NotificationService/NotificationService.swift b/Sources/Extensions/NotificationService/NotificationService.swift
index aa5fdf04f2..8bee214bd6 100644
--- a/Sources/Extensions/NotificationService/NotificationService.swift
+++ b/Sources/Extensions/NotificationService/NotificationService.swift
@@ -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 in
Current.Log.error("failed to get content, giving default: \(error)")
return .value(request.content)
+ }.then { content -> Guarantee 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")
}
}
diff --git a/Sources/Extensions/NotificationService/Resources/Info.plist b/Sources/Extensions/NotificationService/Resources/Info.plist
index b6189964ff..55b94fe35f 100644
--- a/Sources/Extensions/NotificationService/Resources/Info.plist
+++ b/Sources/Extensions/NotificationService/Resources/Info.plist
@@ -25,8 +25,19 @@
NSAllowsArbitraryLoads
+ NSUserActivityTypes
+
+ INSendMessageIntent
+
NSExtension
+ NSExtensionAttributes
+
+ IntentsSupported
+
+ INSendMessageIntent
+
+
NSExtensionPointIdentifier
com.apple.usernotifications.service
NSExtensionPrincipalClass
diff --git a/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift
index 1989e5b624..53970dad97 100644
--- a/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift
+++ b/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift
@@ -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"]
@@ -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(
_ key: Key,
diff --git a/Sources/PushServer/Tests/SharedPushTests/NotificationParserLegacy.test.swift b/Sources/PushServer/Tests/SharedPushTests/NotificationParserLegacy.test.swift
index 84d67eb197..004968b966 100644
--- a/Sources/PushServer/Tests/SharedPushTests/NotificationParserLegacy.test.swift
+++ b/Sources/PushServer/Tests/SharedPushTests/NotificationParserLegacy.test.swift
@@ -1,5 +1,5 @@
import Foundation
-import SharedPush
+@testable import SharedPush
import XCTest
class NotificationParserLegacyTests: XCTestCase {
@@ -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")
+ }
}
diff --git a/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/notification_icon.json b/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/notification_icon.json
new file mode 100644
index 0000000000..c79f39ffa2
--- /dev/null
+++ b/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/notification_icon.json
@@ -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"
+ }
+}
diff --git a/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift b/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift
index a87ea07cde..2525724e38 100644
--- a/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift
+++ b/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift
@@ -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")
@@ -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
@@ -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 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 in
add(UNNotificationRequest(identifier: event.identifier, content: content, trigger: nil))
}.then { [subscription] () -> Promise in
diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift
new file mode 100644
index 0000000000..582afad751
--- /dev/null
+++ b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift
@@ -0,0 +1,190 @@
+import Foundation
+import ImageIO
+import Intents
+import PromiseKit
+import UIKit
+import UserNotifications
+
+public protocol NotificationCommunicationDecorator {
+ func decorate(
+ content: UNNotificationContent,
+ sender: NotificationSenderInfo,
+ api: HomeAssistantAPI?
+ ) -> Guarantee
+}
+
+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 {
+ 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 {
+ avatarImage(for: sender.source, api: api).then { [self] image -> Guarantee 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
+ )
+ return Guarantee { seal in
+ let interaction = INInteraction(intent: intent, response: nil)
+ interaction.direction = .incoming
+ interaction.donate { error in
+ if let error { Current.Log.error("INInteraction donate failed: \(error)") }
+ seal(intent)
+ }
+ }
+ }
+ }
+
+ /// MDI path is synchronous (no network). URL path downloads, caches, and downsamples the avatar.
+ private func avatarImage(
+ for source: NotificationSenderInfo.Source,
+ api: HomeAssistantAPI?
+ ) -> Guarantee {
+ 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 serverID = api?.server.identifier.rawValue
+ let cacheKey = notificationIconCacheKey(for: url, serverID: serverID)
+ if let cached = cache.data(forKey: cacheKey) {
+ return .value(INImage(imageData: cached))
+ }
+ guard let api else {
+ Current.Log.error("Cannot download notification avatar without HomeAssistantAPI context")
+ return .value(nil)
+ }
+ 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)
+ }
+ }
+ }
+ }
+
+ /// 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)"
+ }
+
+ 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
+ }
+}
diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift b/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift
new file mode 100644
index 0000000000..af5c5c2eb5
--- /dev/null
+++ b/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift
@@ -0,0 +1,113 @@
+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)
+}
+
+public func notificationIconCacheKey(for url: URL, serverID: String? = nil) -> String {
+ let stringToHash: String
+ if let serverID {
+ stringToHash = "\(serverID)|\(url.absoluteString)"
+ } else {
+ stringToHash = url.absoluteString
+ }
+ let digest = SHA256.hash(data: Data(stringToHash.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)
+ }
+ }
+}
diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift
new file mode 100644
index 0000000000..4fe82d78c6
--- /dev/null
+++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift
@@ -0,0 +1,53 @@
+import Foundation
+
+// UIKit is available on watchOS (limited subset). UIColor is used in Source.mdi;
+// MDI rendering itself is guarded by #if os(iOS) in NotificationCommunicationDecorator.
+import UIKit
+
+/// Parsed representation of the icon/sender fields in a push payload that should
+/// trigger Communication Notification styling.
+public struct NotificationSenderInfo: Equatable {
+ public enum Source: Equatable {
+ /// User-supplied image URL. `needsAuth` is true when the URL string begins with `/`
+ /// (matching the rule in `NotificationAttachmentParserURL`).
+ case iconURL(URL, needsAuth: Bool)
+
+ /// Built-in Material Design Icon, rendered onto a colored square.
+ /// `background` defaults to `AppConstants.tintColor` when `color` is absent.
+ /// `foreground` defaults to `.white` when `notification_icon_color` is absent.
+ case mdi(
+ name: String,
+ background: UIColor,
+ foreground: UIColor,
+ colorString: String?,
+ iconColorString: String?
+ )
+
+ public static func == (lhs: Source, rhs: Source) -> Bool {
+ switch (lhs, rhs) {
+ case let (.iconURL(lhsURL, lhsNeedsAuth), .iconURL(rhsURL, rhsNeedsAuth)):
+ return lhsURL == rhsURL && lhsNeedsAuth == rhsNeedsAuth
+ case let (
+ .mdi(lhsName, lhsBg, lhsFg, lhsColStr, lhsIconColStr),
+ .mdi(rhsName, rhsBg, rhsFg, rhsColStr, rhsIconColStr)
+ ):
+ if lhsName != rhsName { return false }
+ let bgEqual = (lhsColStr != nil && rhsColStr != nil) ? (lhsColStr == rhsColStr) : lhsBg.isEqual(rhsBg)
+ let fgEqual = (lhsIconColStr != nil && rhsIconColStr != nil) ? (lhsIconColStr == rhsIconColStr) : lhsFg
+ .isEqual(rhsFg)
+ return bgEqual && fgEqual
+ default:
+ return false
+ }
+ }
+ }
+
+ public let source: Source
+ /// The notification's title — used as the sender's display name. Required, non-empty.
+ public let senderName: String
+
+ public init(source: Source, senderName: String) {
+ self.source = source
+ self.senderName = senderName
+ }
+}
diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift b/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift
new file mode 100644
index 0000000000..3845ba1b7c
--- /dev/null
+++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift
@@ -0,0 +1,56 @@
+import Foundation
+import UIColor_Hex_Swift
+import UIKit
+import UserNotifications
+
+public enum NotificationSenderParser {
+ public static func parse(from content: UNNotificationContent) -> NotificationSenderInfo? {
+ let senderName = content.title
+ guard !senderName.isEmpty else { return nil }
+
+ let userInfo = content.userInfo
+ let nestedData = userInfo["data"] as? [String: Any]
+
+ func value(forKey key: String) -> Any? {
+ userInfo[key] ?? nestedData?[key]
+ }
+
+ // icon_url wins when both are present.
+ if let urlString = value(forKey: "icon_url") as? String,
+ !urlString.isEmpty,
+ let url = URL(string: urlString) {
+ return NotificationSenderInfo(
+ source: .iconURL(url, needsAuth: urlString.hasPrefix("/")),
+ senderName: senderName
+ )
+ }
+
+ if let mdiName = value(forKey: "notification_icon") as? String, !mdiName.isEmpty {
+ let colorString = value(forKey: "color") as? String
+ let iconColorString = value(forKey: "notification_icon_color") as? String
+ let background = colorString.flatMap(Self.color(fromHex:))
+ ?? AppConstants.tintColor
+ let foreground = iconColorString.flatMap(Self.color(fromHex:))
+ ?? .white
+ return NotificationSenderInfo(
+ source: .mdi(
+ name: mdiName,
+ background: background,
+ foreground: foreground,
+ colorString: colorString,
+ iconColorString: iconColorString
+ ),
+ senderName: senderName
+ )
+ }
+
+ return nil
+ }
+
+ /// Returns nil for malformed inputs so the caller can fall back to a default,
+ /// instead of relying on UIColor(hex:)'s crash-on-bad-input behavior.
+ private static func color(fromHex hex: String) -> UIColor? {
+ guard let color = try? UIColor(rgba_throws: hex) else { return nil }
+ return color
+ }
+}
diff --git a/Sources/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/SharedPush/Sources/NotificationParserLegacy.swift
index 58be8ff010..10fccf7f21 100644
--- a/Sources/SharedPush/Sources/NotificationParserLegacy.swift
+++ b/Sources/SharedPush/Sources/NotificationParserLegacy.swift
@@ -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"]
@@ -221,7 +231,9 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser {
homeassistant["live_update"] = true
for key in [
"tag", "critical_text", "progress", "progress_max", "chronometer",
- "when", "when_relative", "notification_icon", "notification_icon_color",
+ "when", "when_relative",
+ NotificationDecorationPayloadKey.notificationIcon.rawValue,
+ NotificationDecorationPayloadKey.notificationIconColor.rawValue,
] {
if let value = data[key] {
homeassistant[key] = value
@@ -281,6 +293,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(
_ key: Key,
diff --git a/Tests/Shared/LocalPushEvent.test.swift b/Tests/Shared/LocalPushEvent.test.swift
index 2239b36766..fa6d4bd5c0 100644
--- a/Tests/Shared/LocalPushEvent.test.swift
+++ b/Tests/Shared/LocalPushEvent.test.swift
@@ -57,6 +57,26 @@ class LocalPushEventTests: XCTestCase {
XCTAssertEqual(content.interruptionLevel, .active)
}
+ func testNotificationIconFromDataIsPreservedForDecoration() throws {
+ let data = HAData.dictionary([
+ "message": "some_message",
+ "title": "Phone",
+ "data": [
+ "notification_icon": "mdi:cellphone",
+ "notification_icon_color": "#FFFFFF",
+ "color": "#03A9F4",
+ ],
+ ])
+
+ let content = try LocalPushEvent(data: data).content(server: server)
+
+ XCTAssertEqual(content.title, "Phone")
+ XCTAssertEqual(content.userInfo["notification_icon"] as? String, "mdi:cellphone")
+ XCTAssertEqual(content.userInfo["notification_icon_color"] as? String, "#FFFFFF")
+ XCTAssertEqual(content.userInfo["color"] as? String, "#03A9F4")
+ XCTAssertNotNil(NotificationSenderParser.parse(from: content))
+ }
+
func testFullWithoutSound() {
let event = LocalPushEvent(
headers: [:],
diff --git a/Tests/Shared/LocalPushManager.test.swift b/Tests/Shared/LocalPushManager.test.swift
index d63dc91d69..f6a25a0b59 100644
--- a/Tests/Shared/LocalPushManager.test.swift
+++ b/Tests/Shared/LocalPushManager.test.swift
@@ -47,13 +47,21 @@ class LocalPushManagerTests: XCTestCase {
}
}
- private func setUpManager(webhookID: String, version: Version? = nil) {
+ private func setUpManager(
+ webhookID: String,
+ version: Version? = nil,
+ notificationCommunicationDecorator: NotificationCommunicationDecorator =
+ NotificationCommunicationDecoratorImpl()
+ ) {
api.server.info.connection.webhookID = webhookID
if let version {
api.server.info.version = version
}
- manager = LocalPushManager(server: api.server)
+ manager = LocalPushManager(
+ server: api.server,
+ notificationCommunicationDecorator: notificationCommunicationDecorator
+ )
manager.add = { [weak self] request in
let (promise, resolver) = Promise.pending()
self?.added.append((request, resolver))
@@ -381,6 +389,62 @@ class LocalPushManagerTests: XCTestCase {
.contains(where: { $0.request.type == "mobile_app/push_notification_confirm" })
)
}
+
+ func testEventCommunicationDecoratorInvoked() throws {
+ class SpyDecorator: NotificationCommunicationDecorator {
+ var decorateCalled = false
+ var apiUsed: HomeAssistantAPI?
+
+ func decorate(
+ content: UNNotificationContent,
+ sender: NotificationSenderInfo,
+ api: HomeAssistantAPI?
+ ) -> Guarantee {
+ decorateCalled = true
+ apiUsed = api
+ return .value(content)
+ }
+ }
+
+ let spy = SpyDecorator()
+
+ setUpManager(webhookID: "webhook1", notificationCommunicationDecorator: spy)
+
+ let expectation1 = expectation(description: "contentRequestsChanged")
+ attachmentManager.contentRequestsChanged = {
+ expectation1.fulfill()
+ }
+
+ let sub = try XCTUnwrap(apiConnection.pendingSubscriptions.first)
+ sub.handler(sub.cancellable, .dictionary([
+ "message": "test_message",
+ "notification_icon": "mdi:dishwasher",
+ "data": [
+ "tag": "test_tag",
+ ],
+ ]))
+
+ waitForExpectations(timeout: 10.0)
+
+ let req = try XCTUnwrap(attachmentManager.contentRequests.first)
+ req.1(with(UNMutableNotificationContent()) {
+ $0.body = "test_message_modified"
+ $0.title = "test_title"
+ $0.userInfo = [
+ "notification_icon": "mdi:dishwasher",
+ ]
+ })
+
+ let expectation2 = expectation(description: "addedChanged")
+ addedChanged = {
+ expectation2.fulfill()
+ }
+
+ waitForExpectations(timeout: 10.0)
+
+ XCTAssertTrue(spy.decorateCalled)
+ XCTAssertIdentical(spy.apiUsed, api)
+ }
}
private class FakeNotificationAttachmentManager: NotificationAttachmentManager {
diff --git a/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift
new file mode 100644
index 0000000000..3578e1fda4
--- /dev/null
+++ b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift
@@ -0,0 +1,194 @@
+import Intents
+import OHHTTPStubs
+import PromiseKit
+@testable import Shared
+import UIKit
+import UserNotifications
+import XCTest
+
+final class NotificationCommunicationDecoratorTests: XCTestCase {
+ private var cache: InMemoryIconCache!
+ private var decorator: NotificationCommunicationDecoratorImpl!
+ private var api: FakeHomeAssistantAPI!
+
+ override func setUp() {
+ super.setUp()
+ cache = InMemoryIconCache()
+ decorator = NotificationCommunicationDecoratorImpl(cache: cache)
+ api = FakeHomeAssistantAPI(server: .fake())
+ }
+
+ override func tearDown() {
+ HTTPStubs.removeAllStubs()
+ super.tearDown()
+ }
+
+ private func content(title: String = "Dishwasher", body: String = "Cycle complete.") -> UNNotificationContent {
+ let c = UNMutableNotificationContent()
+ c.title = title
+ c.body = body
+ return c
+ }
+
+ // MARK: - MDI
+
+ func testBuildIntent_mdi_setsSenderNameAndImage() throws {
+ let info = NotificationSenderInfo(
+ source: .mdi(
+ name: "mdi:door",
+ background: .red,
+ foreground: .white,
+ colorString: "#FF0000",
+ iconColorString: "#FFFFFF"
+ ),
+ senderName: "Front Door"
+ )
+ let intent = try hang(Promise(decorator.buildIntent(
+ sender: info,
+ title: "Front Door",
+ body: "Opened",
+ api: api
+ )))
+
+ XCTAssertEqual(intent.sender?.displayName, "Front Door")
+ XCTAssertNotNil(intent.sender?.image, "MDI source must produce a non-nil INImage")
+ XCTAssertEqual(intent.content, "Opened")
+ XCTAssertEqual(intent.serviceName, "HomeAssistant")
+ }
+
+ func testBuildIntent_conversationIdentifier_stableAcrossCalls() throws {
+ let info = NotificationSenderInfo(
+ source: .mdi(
+ name: "mdi:door",
+ background: .red,
+ foreground: .white,
+ colorString: "#FF0000",
+ iconColorString: "#FFFFFF"
+ ),
+ senderName: "Front Door"
+ )
+ let intent1 = try hang(Promise(decorator.buildIntent(sender: info, title: "Front Door", body: "x", api: api)))
+ let intent2 = try hang(Promise(decorator.buildIntent(sender: info, title: "Front Door", body: "y", api: api)))
+ XCTAssertEqual(intent1.conversationIdentifier, intent2.conversationIdentifier)
+ XCTAssertFalse(intent1.conversationIdentifier?.isEmpty ?? true)
+ }
+
+ func testBuildIntent_conversationIdentifier_differsForDifferentSenderNames() throws {
+ let a = NotificationSenderInfo(
+ source: .mdi(
+ name: "mdi:door",
+ background: .red,
+ foreground: .white,
+ colorString: "#FF0000",
+ iconColorString: "#FFFFFF"
+ ),
+ senderName: "Front Door"
+ )
+ let b = NotificationSenderInfo(
+ source: .mdi(
+ name: "mdi:door",
+ background: .red,
+ foreground: .white,
+ colorString: "#FF0000",
+ iconColorString: "#FFFFFF"
+ ),
+ senderName: "Back Door"
+ )
+ let ia = try hang(Promise(decorator.buildIntent(sender: a, title: "Front Door", body: "x", api: api)))
+ let ib = try hang(Promise(decorator.buildIntent(sender: b, title: "Back Door", body: "x", api: api)))
+ XCTAssertNotEqual(ia.conversationIdentifier, ib.conversationIdentifier)
+ }
+
+ func testDecorate_emptyTitle_returnsOriginalContentUnchanged() throws {
+ let original = content(title: "", body: "x")
+ let info = NotificationSenderInfo(
+ source: .mdi(
+ name: "mdi:door",
+ background: .red,
+ foreground: .white,
+ colorString: "#FF0000",
+ iconColorString: "#FFFFFF"
+ ),
+ senderName: "X" // ignored — decorator uses content.title
+ )
+ let result = try hang(Promise(decorator.decorate(content: original, sender: info, api: api)))
+ XCTAssertEqual(result, original)
+ }
+
+ // MARK: - URL
+
+ func testBuildIntent_iconURL_cacheHit_skipsDownload() throws {
+ let url = try XCTUnwrap(URL(string: "https://example.com/avatar.png"))
+ let pngBytes = makeRedPNG() // helper below
+ cache.setData(pngBytes, forKey: notificationIconCacheKey(for: url, serverID: api.server.identifier.rawValue))
+
+ let info = NotificationSenderInfo(
+ source: .iconURL(url, needsAuth: false),
+ senderName: "Alex"
+ )
+ let intent = try hang(Promise(decorator.buildIntent(
+ sender: info, title: "Alex", body: "Hi", api: api
+ )))
+ XCTAssertNotNil(intent.sender?.image)
+ // Cache hit means no HTTP call should have occurred. We assert that by
+ // confirming no stubs are required for this test to pass.
+ }
+
+ func testBuildIntent_iconURL_cacheMiss_downloadsThenCaches() throws {
+ let url = try XCTUnwrap(URL(string: "https://homeassistant.local:8123/icon.png"))
+ let pngBytes = makeRedPNG()
+
+ let stubDesc = HTTPStubs.stubRequests(passingTest: { $0.url == url }) { _ in
+ HTTPStubsResponse(data: pngBytes, statusCode: 200, headers: nil)
+ }
+ defer { HTTPStubs.removeStub(stubDesc) }
+
+ let info = NotificationSenderInfo(
+ source: .iconURL(url, needsAuth: false),
+ senderName: "Alex"
+ )
+ let intent = try hang(Promise(decorator.buildIntent(
+ sender: info, title: "Alex", body: "Hi", api: api
+ )))
+ XCTAssertNotNil(intent.sender?.image)
+ XCTAssertNotNil(
+ cache.data(forKey: notificationIconCacheKey(for: url, serverID: api.server.identifier.rawValue)),
+ "after download, the image must be cached"
+ )
+ }
+
+ func testBuildIntent_iconURL_downloadFails_returnsIntentWithNilImage() throws {
+ let url = try XCTUnwrap(URL(string: "https://homeassistant.local:8123/missing.png"))
+ let stubDesc = HTTPStubs.stubRequests(passingTest: { $0.url == url }) { _ in
+ HTTPStubsResponse(data: Data(), statusCode: 500, headers: nil)
+ }
+ defer { HTTPStubs.removeStub(stubDesc) }
+
+ let info = NotificationSenderInfo(
+ source: .iconURL(url, needsAuth: false),
+ senderName: "Alex"
+ )
+ let intent = try hang(Promise(decorator.buildIntent(
+ sender: info, title: "Alex", body: "Hi", api: api
+ )))
+ XCTAssertNil(intent.sender?.image, "failed download must produce a nil image, not crash")
+ XCTAssertEqual(intent.sender?.displayName, "Alex", "we still build a sender so styling proceeds")
+ }
+
+ private func makeRedPNG() -> Data {
+ UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32)).pngData { _ in
+ UIColor.red.setFill()
+ UIRectFill(CGRect(x: 0, y: 0, width: 32, height: 32))
+ }
+ }
+}
+
+// MARK: - Test doubles
+
+private final class InMemoryIconCache: NotificationIconCache {
+ var store: [String: Data] = [:]
+ func data(forKey key: String) -> Data? { store[key] }
+ func setData(_ data: Data, forKey key: String) { store[key] = data }
+}
+
+private final class FakeHomeAssistantAPI: HomeAssistantAPI {}
diff --git a/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift b/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift
new file mode 100644
index 0000000000..c4135029e8
--- /dev/null
+++ b/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift
@@ -0,0 +1,78 @@
+@testable import Shared
+import XCTest
+
+final class NotificationIconCacheTests: XCTestCase {
+ private var cache: NotificationIconCacheImpl!
+ private var tempDir: URL!
+
+ override func setUp() {
+ super.setUp()
+ tempDir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("NotificationIconCacheTests-\(UUID().uuidString)", isDirectory: true)
+ try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
+ cache = NotificationIconCacheImpl(directory: tempDir, maxEntries: 3)
+ }
+
+ override func tearDown() {
+ try? FileManager.default.removeItem(at: tempDir)
+ super.tearDown()
+ }
+
+ func testMissReturnsNil() {
+ XCTAssertNil(cache.data(forKey: "missing"))
+ }
+
+ func testWriteThenRead_roundTrips() {
+ let payload = Data([0x89, 0x50, 0x4E, 0x47]) // PNG magic
+ cache.setData(payload, forKey: "abc")
+ XCTAssertEqual(cache.data(forKey: "abc"), payload)
+ }
+
+ func testEviction_dropsOldestWhenOverLimit() throws {
+ cache.setData(Data([1]), forKey: "k1")
+ cache.setData(Data([2]), forKey: "k2")
+ cache.setData(Data([3]), forKey: "k3")
+
+ let fm = FileManager.default
+ let now = Date()
+ try fm.setAttributes(
+ [.modificationDate: now.addingTimeInterval(-30)],
+ ofItemAtPath: tempDir.appendingPathComponent("k1").path
+ )
+ try fm.setAttributes(
+ [.modificationDate: now.addingTimeInterval(-20)],
+ ofItemAtPath: tempDir.appendingPathComponent("k2").path
+ )
+ try fm.setAttributes(
+ [.modificationDate: now.addingTimeInterval(-10)],
+ ofItemAtPath: tempDir.appendingPathComponent("k3").path
+ )
+
+ cache.setData(Data([4]), forKey: "k4") // triggers eviction; max is 3
+
+ XCTAssertNil(cache.data(forKey: "k1"), "k1 should be evicted")
+ XCTAssertEqual(cache.data(forKey: "k4"), Data([4]))
+ }
+
+ func testKeyHashing_returnsSameKeyForSameURL() throws {
+ let key1 = try notificationIconCacheKey(for: XCTUnwrap(URL(string: "https://example.com/a.png")))
+ let key2 = try notificationIconCacheKey(for: XCTUnwrap(URL(string: "https://example.com/a.png")))
+ XCTAssertEqual(key1, key2)
+ }
+
+ func testKeyHashing_returnsDifferentKeysForDifferentURLs() throws {
+ let k1 = try notificationIconCacheKey(for: XCTUnwrap(URL(string: "https://example.com/a.png")))
+ let k2 = try notificationIconCacheKey(for: XCTUnwrap(URL(string: "https://example.com/b.png")))
+ XCTAssertNotEqual(k1, k2)
+ }
+
+ func testKeyHashing_withServerID() throws {
+ let url = try XCTUnwrap(URL(string: "https://example.com/a.png"))
+ let keyWithoutServer = notificationIconCacheKey(for: url)
+ let keyWithServer1 = notificationIconCacheKey(for: url, serverID: "server1")
+ let keyWithServer2 = notificationIconCacheKey(for: url, serverID: "server2")
+
+ XCTAssertNotEqual(keyWithoutServer, keyWithServer1)
+ XCTAssertNotEqual(keyWithServer1, keyWithServer2)
+ }
+}
diff --git a/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift b/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift
new file mode 100644
index 0000000000..889ff51ac0
--- /dev/null
+++ b/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift
@@ -0,0 +1,60 @@
+@testable import Shared
+import UIKit
+import XCTest
+
+final class NotificationSenderInfoTests: XCTestCase {
+ func testEquatable_sameValues_areEqual() {
+ let a = NotificationSenderInfo(
+ source: .mdi(
+ name: "mdi:door",
+ background: .red,
+ foreground: .white,
+ colorString: nil,
+ iconColorString: nil
+ ),
+ senderName: "Front Door"
+ )
+ let b = NotificationSenderInfo(
+ source: .mdi(
+ name: "mdi:door",
+ background: .red,
+ foreground: .white,
+ colorString: nil,
+ iconColorString: nil
+ ),
+ senderName: "Front Door"
+ )
+ XCTAssertEqual(a, b)
+ }
+
+ func testEquatable_differentSenderName_areNotEqual() {
+ let a = NotificationSenderInfo(
+ source: .mdi(
+ name: "mdi:door",
+ background: .red,
+ foreground: .white,
+ colorString: nil,
+ iconColorString: nil
+ ),
+ senderName: "Front Door"
+ )
+ let b = NotificationSenderInfo(
+ source: .mdi(
+ name: "mdi:door",
+ background: .red,
+ foreground: .white,
+ colorString: nil,
+ iconColorString: nil
+ ),
+ senderName: "Back Door"
+ )
+ XCTAssertNotEqual(a, b)
+ }
+
+ func testEquatable_iconURLNeedsAuthDiffers_areNotEqual() throws {
+ let url = try XCTUnwrap(URL(string: "/local/x.png"))
+ let a = NotificationSenderInfo(source: .iconURL(url, needsAuth: true), senderName: "X")
+ let b = NotificationSenderInfo(source: .iconURL(url, needsAuth: false), senderName: "X")
+ XCTAssertNotEqual(a, b)
+ }
+}
diff --git a/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift b/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift
new file mode 100644
index 0000000000..782682d3b4
--- /dev/null
+++ b/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift
@@ -0,0 +1,135 @@
+@testable import Shared
+import UIKit
+import UserNotifications
+import XCTest
+
+final class NotificationSenderParserTests: XCTestCase {
+ private func content(title: String = "Hi", userInfo: [AnyHashable: Any]) -> UNNotificationContent {
+ let c = UNMutableNotificationContent()
+ c.title = title
+ c.userInfo = userInfo
+ return c
+ }
+
+ /// Asserts that a UIColor resolves to the same values as `AppConstants.tintColor`
+ /// in both light and dark trait collections.
+ ///
+ /// Direct `XCTAssertEqual(color, AppConstants.tintColor)` is unreliable: `tintColor`
+ /// is a dynamic `UIColor` built from a closure, and UIColor equality on two
+ /// independently-constructed dynamic providers compares closure identity, not
+ /// resolved component values. We resolve both sides in each trait collection
+ /// and compare those concrete values, which keeps the assertion in sync with
+ /// whatever `tintColor`'s provider currently returns.
+ private func assertIsTintColor(_ color: UIColor, file: StaticString = #file, line: UInt = #line) {
+ let expected = AppConstants.tintColor
+ for style in [UIUserInterfaceStyle.light, .dark] {
+ let traits = UITraitCollection(userInterfaceStyle: style)
+ XCTAssertEqual(
+ color.resolvedColor(with: traits),
+ expected.resolvedColor(with: traits),
+ "Expected tint-color-equivalent in style \(style), but got \(color)",
+ file: file,
+ line: line
+ )
+ }
+ }
+
+ func testNoIconFields_returnsNil() {
+ XCTAssertNil(NotificationSenderParser.parse(from: content(userInfo: [:])))
+ }
+
+ func testEmptyTitle_returnsNil_evenWithIcon() {
+ let c = content(title: "", userInfo: ["notification_icon": "mdi:door"])
+ XCTAssertNil(NotificationSenderParser.parse(from: c))
+ }
+
+ func testMdiOnly_defaults() {
+ let parsed = NotificationSenderParser.parse(from: content(userInfo: [
+ "notification_icon": "mdi:door",
+ ]))
+ guard case let .mdi(name, background, foreground, _, _) = parsed?.source else {
+ return XCTFail("expected mdi source, got \(String(describing: parsed))")
+ }
+ XCTAssertEqual(name, "mdi:door")
+ assertIsTintColor(background)
+ XCTAssertEqual(foreground, UIColor.white)
+ XCTAssertEqual(parsed?.senderName, "Hi")
+ }
+
+ func testMdiInNestedData_defaults() {
+ let parsed = NotificationSenderParser.parse(from: content(userInfo: [
+ "data": [
+ "notification_icon": "mdi:cellphone",
+ ],
+ ]))
+ guard case let .mdi(name, background, foreground, _, _) = parsed?.source else {
+ return XCTFail("expected mdi source, got \(String(describing: parsed))")
+ }
+ XCTAssertEqual(name, "mdi:cellphone")
+ assertIsTintColor(background)
+ XCTAssertEqual(foreground, UIColor.white)
+ XCTAssertEqual(parsed?.senderName, "Hi")
+ }
+
+ func testMdiWithColor_appliedAsBackground() {
+ let parsed = NotificationSenderParser.parse(from: content(userInfo: [
+ "notification_icon": "mdi:door",
+ "color": "#2196F3",
+ ]))
+ guard case let .mdi(_, background, _, _, _) = parsed?.source else { return XCTFail() }
+ XCTAssertEqual(background, UIColor(hex: "#2196F3"))
+ }
+
+ func testMdiWithNotificationIconColor_appliedAsForeground() {
+ let parsed = NotificationSenderParser.parse(from: content(userInfo: [
+ "notification_icon": "mdi:door",
+ "notification_icon_color": "#FF5722",
+ ]))
+ guard case let .mdi(_, _, foreground, _, _) = parsed?.source else { return XCTFail() }
+ XCTAssertEqual(foreground, UIColor(hex: "#FF5722"))
+ }
+
+ func testMdiWithMalformedColor_fallsBackToDefault() {
+ let parsed = NotificationSenderParser.parse(from: content(userInfo: [
+ "notification_icon": "mdi:door",
+ "color": "not-a-color",
+ ]))
+ guard case let .mdi(_, background, _, _, _) = parsed?.source else { return XCTFail() }
+ assertIsTintColor(background)
+ }
+
+ func testIconURLAbsolute_noAuth() throws {
+ let parsed = NotificationSenderParser.parse(from: content(userInfo: [
+ "icon_url": "https://example.com/x.png",
+ ]))
+ guard case let .iconURL(url, needsAuth) = parsed?.source else { return XCTFail() }
+ XCTAssertEqual(url, try XCTUnwrap(URL(string: "https://example.com/x.png")))
+ XCTAssertFalse(needsAuth)
+ }
+
+ func testIconURLRelative_needsAuth() throws {
+ let parsed = NotificationSenderParser.parse(from: content(userInfo: [
+ "icon_url": "/local/x.png",
+ ]))
+ guard case let .iconURL(url, needsAuth) = parsed?.source else { return XCTFail() }
+ XCTAssertEqual(url, try XCTUnwrap(URL(string: "/local/x.png")))
+ XCTAssertTrue(needsAuth)
+ }
+
+ func testIconURLInvalidString_returnsNil() {
+ let parsed = NotificationSenderParser.parse(from: content(userInfo: [
+ "icon_url": "",
+ ]))
+ XCTAssertNil(parsed)
+ }
+
+ func testIconURLWinsOverNotificationIcon() {
+ let parsed = NotificationSenderParser.parse(from: content(userInfo: [
+ "notification_icon": "mdi:door",
+ "icon_url": "https://example.com/x.png",
+ ]))
+ guard case .iconURL = parsed?.source else {
+ return XCTFail("icon_url must take precedence")
+ }
+ }
+}