From 7ccd85124795f3e8c608daf1f70f12fb4038a38d Mon Sep 17 00:00:00 2001 From: Roei Bracha Date: Wed, 27 May 2026 20:25:30 +0300 Subject: [PATCH 1/8] Add custom notification icons for automations --- Gemfile | 1 + Gemfile.lock | 4 +- HomeAssistant.xcodeproj/project.pbxproj | 117 +++++++---- .../communication_notification.apns | 14 ++ .../NotificationService.swift | 14 +- Sources/Shared/Environment/Environment.swift | 2 + .../NotificationCommunicationDecorator.swift | 194 ++++++++++++++++++ .../NotificationIconCache.swift | 110 ++++++++++ .../NotificationSenderInfo.swift | 29 +++ .../NotificationSenderParser.swift | 43 ++++ ...ificationCommunicationDecoratorTests.swift | 159 ++++++++++++++ .../NotificationIconCacheTests.swift | 55 +++++ .../NotificationSenderInfoTests.swift | 36 ++++ .../NotificationSenderParserTests.swift | 120 +++++++++++ 14 files changed, 853 insertions(+), 45 deletions(-) create mode 100644 Sources/Extensions/NotificationContent/Resources/TestNotifications/communication_notification.apns create mode 100644 Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift create mode 100644 Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift create mode 100644 Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift create mode 100644 Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift create mode 100644 Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift create mode 100644 Tests/Shared/NotificationSender/NotificationIconCacheTests.swift create mode 100644 Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift create mode 100644 Tests/Shared/NotificationSender/NotificationSenderParserTests.swift diff --git a/Gemfile b/Gemfile index b313241019..49c34ef934 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,7 @@ source 'https://rubygems.org' gem 'cocoapods' gem 'cocoapods-acknowledgements' gem 'fastlane', '2.222.0' +gem 'abbrev' gem 'rubocop', require: false plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') diff --git a/Gemfile.lock b/Gemfile.lock index 139006a928..c8cfc93faa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,7 @@ GEM base64 nkf rexml + abbrev (0.1.2) activesupport (7.1.3) base64 bigdecimal @@ -326,6 +327,7 @@ PLATFORMS x86_64-darwin-23 DEPENDENCIES + abbrev cocoapods cocoapods-acknowledgements fastlane (= 2.222.0) @@ -335,4 +337,4 @@ DEPENDENCIES rubocop BUNDLED WITH - 2.2.2 + 2.7.2 diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index a5d1dbc80e..5f4d1cb0e7 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 */; }; 1100D51F2496F63400B1073C /* ThemeColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1100D51E2496F63400B1073C /* ThemeColors.swift */; }; 1101568324D770B2009424C9 /* iOSTagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1161C01624D75BD500A0E3C4 /* iOSTagManager.swift */; }; @@ -463,6 +465,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 */; }; @@ -952,7 +956,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 */; }; @@ -1250,10 +1253,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 */; }; @@ -1261,14 +1265,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 */; }; @@ -1532,9 +1539,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 */; }; @@ -1544,6 +1553,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, ); }; }; @@ -1589,10 +1599,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 */; }; @@ -2256,7 +2266,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 = ""; }; @@ -2843,7 +2855,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 = ""; }; @@ -3084,8 +3095,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 = ""; }; @@ -3094,17 +3107,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 = ""; }; @@ -3116,10 +3131,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 = ""; }; @@ -3132,7 +3148,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 = ""; }; @@ -3143,6 +3159,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 = ""; }; @@ -3462,7 +3479,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 = ""; }; @@ -3511,6 +3528,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 = ""; }; @@ -3733,6 +3751,7 @@ 110FB4512499DB3A000865B4 /* map_notification.apns */, 1100D52024974D6700B1073C /* camera_notification.apns */, 11169B3E262BCEE6005EF90A /* dynamic_notification.apns */, + E1AB8533E2EF222138FF0E38 /* communication_notification.apns */, ); path = TestNotifications; sourceTree = ""; @@ -6521,6 +6540,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 = ( @@ -7175,6 +7205,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 = ( @@ -7327,15 +7368,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 */, @@ -7491,6 +7533,7 @@ 11ADF93D267D34A20040A7E3 /* NotificationCommands */, 11A3BD2B261921FC005237E6 /* LocalPush */, 11F2F21725871C1700F61F7C /* NotificationAttachments */, + 4926A2CC8C41FFA25A4AEFED /* NotificationSender */, 11169B9A262BE3E1005EF90A /* UNNotificationContent+Additions.swift */, ); path = Notifications; @@ -8219,7 +8262,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" */, @@ -8748,14 +8791,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"; @@ -8893,14 +8932,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"; @@ -8936,14 +8971,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"; @@ -9043,14 +9074,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"; @@ -10113,6 +10140,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 */, @@ -10123,6 +10151,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; }; @@ -10482,6 +10513,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; }; @@ -10500,12 +10535,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 */, @@ -10554,6 +10590,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; }; @@ -12387,7 +12426,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = { + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = { isa = XCLocalSwiftPackageReference; relativePath = Sources/SharedPush; }; @@ -12459,7 +12498,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..9525b8cad3 100644 --- a/Sources/Extensions/NotificationService/NotificationService.swift +++ b/Sources/Extensions/NotificationService/NotificationService.swift @@ -9,25 +9,29 @@ final class NotificationService: UNNotificationServiceExtension { ) { Current.Log.info("didReceive \(request), user info \(request.content.userInfo)") - guard let server = Current.servers.server(for: request.content), let api = Current.api(for: server) else { + guard let server = Current.servers.server(for: request.content), + let api = Current.api(for: server) else { contentHandler(request.content) return } firstly { Current.notificationAttachmentManager.content(from: request.content, api: api) - }.recover { error in + }.recover { error -> Guarantee 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 Current.notificationCommunicationDecorator + .decorate(content: content, sender: sender, api: api) }.done { contentHandler($0) } } override func serviceExtensionTimeWillExpire() { - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, - // otherwise the original push payload will be used. Current.Log.warning("serviceExtensionTimeWillExpire") } } diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 8dfbc722bb..5fcd3d4646 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -271,6 +271,8 @@ public class AppEnvironment { public var updater = Updater() public var serverAlerter = ServerAlerter() public var notificationAttachmentManager: NotificationAttachmentManager = NotificationAttachmentManagerImpl() + public var notificationCommunicationDecorator: NotificationCommunicationDecorator = + NotificationCommunicationDecoratorImpl() /// Dispatchque local notifications (From the App to the App, not from Home Assistant) public var notificationDispatcher: LocalNotificationDispatcherProtocol = LocalNotificationDispatcher() diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift new file mode 100644 index 0000000000..a807a5a07b --- /dev/null +++ b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift @@ -0,0 +1,194 @@ +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).map { [self] image in + let conversationID = conversationIdentifier(for: sender) + let handle = INPersonHandle(value: conversationID, type: .unknown) + var nameComponents = PersonNameComponents() + nameComponents.nickname = title + let person = INPerson( + personHandle: handle, + nameComponents: nameComponents, + displayName: title, + image: image, + contactIdentifier: nil, + customIdentifier: conversationID + ) + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: body, + speakableGroupName: nil, + conversationIdentifier: conversationID, + serviceName: "HomeAssistant", + sender: person, + attachments: nil + ) + // Donate before returning so that `decorate`'s subsequent call to + // `content.updating(from:)` can associate this notification with the + // conversation. Donation is a global system side-effect (visible in Siri + // suggestions); failures here are logged but never block notification + // delivery, since the styling still applies without the donation. + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate { error in + if let error { Current.Log.error("INInteraction donate failed: \(error)") } + } + return intent + } + } + + /// MDI path is synchronous (no network). URL path downloads, caches, and downsamples the avatar. + 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 cacheKey = notificationIconCacheKey(for: url) + // The cache stores the ORIGINAL downloaded bytes, not the downsampled + // bytes, so a future caller can re-downsample at a different size if + // needed (e.g. notification content extension at higher resolution). + if let cached = cache.data(forKey: cacheKey) { + return .value(Self.image(fromOriginalData: cached)) + } + return Guarantee { seal in + api.DownloadDataAt(url: url, needsAuth: needsAuth).done { [cache] downloadedFile in + guard let data = try? Data(contentsOf: downloadedFile) else { + Current.Log.error("Failed to read downloaded avatar from \(downloadedFile.path) for url \(url)") + seal(nil); return + } + cache.setData(data, forKey: cacheKey) + seal(Self.image(fromOriginalData: data)) + }.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, background, foreground): + iconKey = "\(name)|\(background.hexDescription())|\(foreground.hexDescription())" + case let .iconURL(url, _): + iconKey = url.absoluteString + } + return "ha-sender:\(sender.senderName.lowercased()):\(iconKey)" + } + + /// 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 data + /// isn't a decodable image — caller falls back to the raw data. + /// + /// 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(data: Data, maxDimension: CGFloat) -> Data? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, 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 + } + + /// Wrap original-PNG-or-JPEG bytes in an `INImage`, downsampling first when possible. + /// Falls back to handing the raw bytes to `INImage` if ImageIO can't decode them. + private static func image(fromOriginalData data: Data) -> INImage { + INImage(imageData: downsample(data: data, maxDimension: 256) ?? data) + } +} + +private extension UIColor { + /// Stable hex serialization used only for conversation-ID construction. + func hexDescription() -> String { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + getRed(&r, green: &g, blue: &b, alpha: &a) + return String(format: "#%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255)) + } +} diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift b/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift new file mode 100644 index 0000000000..189c89dae0 --- /dev/null +++ b/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift @@ -0,0 +1,110 @@ +import CryptoKit +import Foundation + +/// Disk-backed byte cache. +/// +/// Keys are opaque to the protocol; use `notificationIconCacheKey(for:)` to derive +/// the canonical key for a URL so different callers reach the same entry. +public protocol NotificationIconCache { + func data(forKey key: String) -> Data? + func setData(_ data: Data, forKey key: String) +} + +/// Canonical cache key for a notification-icon URL. SHA-256 of the absolute URL +/// string with a `.img` suffix. Lives at module scope so callers don't need to +/// reference any concrete cache implementation. +public func notificationIconCacheKey(for url: URL) -> String { + let digest = SHA256.hash(data: Data(url.absoluteString.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + ".img" +} + +public final class NotificationIconCacheImpl: NotificationIconCache { + private let directory: URL + private let maxEntries: Int + private let queue = DispatchQueue(label: "io.home-assistant.NotificationIconCache") + /// Cross-process coordinator: the App Group container is shared between the host app, + /// the Notification Service Extension, and Watch extensions. The serial queue alone + /// only guarantees ordering within this process. + private let coordinator = NSFileCoordinator() + + public convenience init() { + let dir = AppConstants.AppGroupContainer + .appendingPathComponent("notification-icons", isDirectory: true) + self.init(directory: dir, maxEntries: 50) + } + + init(directory: URL, maxEntries: Int) { + self.directory = directory + self.maxEntries = maxEntries + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + Current.Log.error("NotificationIconCache: failed to create directory \(directory.path): \(error)") + } + } + + public func data(forKey key: String) -> Data? { + queue.sync { + let url = directory.appendingPathComponent(key) + var read: Data? + var coordinatorError: NSError? + coordinator.coordinate(readingItemAt: url, error: &coordinatorError) { coordinatedURL in + read = try? Data(contentsOf: coordinatedURL) + } + guard let data = read else { return nil } + // Best-effort mtime touch so LRU tracks reads as well as writes. + // Failure here only means this entry may be evicted slightly earlier — not a + // correctness concern, so no log. + try? FileManager.default.setAttributes( + [.modificationDate: Date()], + ofItemAtPath: url.path + ) + return data + } + } + + public func setData(_ data: Data, forKey key: String) { + queue.sync { + let url = directory.appendingPathComponent(key) + var writeError: Error? + var coordinatorError: NSError? + coordinator + .coordinate(writingItemAt: url, options: .forReplacing, error: &coordinatorError) { coordinatedURL in + do { + try data.write(to: coordinatedURL, options: .atomic) + } catch { + writeError = error + } + } + if let error = (writeError ?? coordinatorError) { + Current.Log.error("NotificationIconCache: write failed for key \(key): \(error)") + } + prune() + } + } + + /// Drop the oldest files until count <= maxEntries. Must be called on `queue`. + private func prune() { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { return } + guard entries.count > maxEntries else { return } + + // Snapshot (url, mtime) once per entry, then sort — avoids re-stat'ing inside + // the comparator and keeps the prefetched cache from `contentsOfDirectory` the + // sole source of mtimes. + let dated = entries.map { url -> (URL, Date) in + let date = ( + try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + ) ?? .distantPast + return (url, date) + } + let sorted = dated.sorted { $0.1 < $1.1 }.map(\.0) + for url in sorted.prefix(entries.count - maxEntries) { + try? FileManager.default.removeItem(at: url) + } + } +} diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift new file mode 100644 index 0000000000..ebed38ff59 --- /dev/null +++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift @@ -0,0 +1,29 @@ +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) + } + + 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..7ae34920f0 --- /dev/null +++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift @@ -0,0 +1,43 @@ +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 + + // icon_url wins when both are present. + if let urlString = userInfo["icon_url"] as? String, + !urlString.isEmpty, + let url = URL(string: urlString) { + return NotificationSenderInfo( + source: .iconURL(url, needsAuth: urlString.hasPrefix("/")), + senderName: senderName + ) + } + + if let mdiName = userInfo["notification_icon"] as? String, !mdiName.isEmpty { + let background = (userInfo["color"] as? String).flatMap(Self.color(fromHex:)) + ?? AppConstants.tintColor + let foreground = (userInfo["notification_icon_color"] as? String).flatMap(Self.color(fromHex:)) + ?? .white + return NotificationSenderInfo( + source: .mdi(name: mdiName, background: background, foreground: foreground), + 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/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift new file mode 100644 index 0000000000..d3ddc6da06 --- /dev/null +++ b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift @@ -0,0 +1,159 @@ +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()) + } + + 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), + 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), + 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), + senderName: "Front Door" + ) + let b = NotificationSenderInfo( + source: .mdi(name: "mdi:door", background: .red, foreground: .white), + 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), + 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)) + + 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)), + "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..56ddf4da19 --- /dev/null +++ b/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift @@ -0,0 +1,55 @@ +@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() { + cache.setData(Data([1]), forKey: "k1") + Thread.sleep(forTimeInterval: 0.01) // ensure distinct mtimes + cache.setData(Data([2]), forKey: "k2") + Thread.sleep(forTimeInterval: 0.01) + cache.setData(Data([3]), forKey: "k3") + Thread.sleep(forTimeInterval: 0.01) + 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) + } +} diff --git a/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift b/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift new file mode 100644 index 0000000000..f3d474d222 --- /dev/null +++ b/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift @@ -0,0 +1,36 @@ +@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), + senderName: "Front Door" + ) + let b = NotificationSenderInfo( + source: .mdi(name: "mdi:door", background: .red, foreground: .white), + senderName: "Front Door" + ) + XCTAssertEqual(a, b) + } + + func testEquatable_differentSenderName_areNotEqual() { + let a = NotificationSenderInfo( + source: .mdi(name: "mdi:door", background: .red, foreground: .white), + senderName: "Front Door" + ) + let b = NotificationSenderInfo( + source: .mdi(name: "mdi:door", background: .red, foreground: .white), + 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..52b8b082f2 --- /dev/null +++ b/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift @@ -0,0 +1,120 @@ +@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, .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") + } + } +} From 8937acb5fd5a56c0bc1e400ad4bb1563298142c3 Mon Sep 17 00:00:00 2001 From: Roei Bracha Date: Wed, 27 May 2026 20:35:16 +0300 Subject: [PATCH 2/8] Address review comments for custom notification icons --- .../NotificationCommunicationDecorator.swift | 54 +++++++++---------- .../NotificationSenderInfo.swift | 8 ++- .../NotificationSenderParser.swift | 14 +++-- ...ificationCommunicationDecoratorTests.swift | 45 ++++++++++++++-- .../NotificationIconCacheTests.swift | 21 ++++++-- .../NotificationSenderInfoTests.swift | 32 +++++++++-- 6 files changed, 127 insertions(+), 47 deletions(-) diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift index a807a5a07b..22353d128e 100644 --- a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift +++ b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift @@ -94,7 +94,7 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica api: HomeAssistantAPI ) -> Guarantee { switch source { - case let .mdi(name, background, foreground): + case let .mdi(name, background, foreground, _, _): #if os(iOS) let image = INImage( icon: MaterialDesignIcons(serversideValueNamed: name, fallback: .bellIcon), @@ -107,20 +107,24 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica #endif case let .iconURL(url, needsAuth): let cacheKey = notificationIconCacheKey(for: url) - // The cache stores the ORIGINAL downloaded bytes, not the downsampled - // bytes, so a future caller can re-downsample at a different size if - // needed (e.g. notification content extension at higher resolution). if let cached = cache.data(forKey: cacheKey) { - return .value(Self.image(fromOriginalData: cached)) + return .value(INImage(imageData: cached)) } return Guarantee { seal in api.DownloadDataAt(url: url, needsAuth: needsAuth).done { [cache] downloadedFile in - guard let data = try? Data(contentsOf: downloadedFile) else { - Current.Log.error("Failed to read downloaded avatar from \(downloadedFile.path) for url \(url)") + 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(data, forKey: cacheKey) - seal(Self.image(fromOriginalData: data)) + 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) @@ -137,17 +141,22 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica private func conversationIdentifier(for sender: NotificationSenderInfo) -> String { let iconKey: String switch sender.source { - case let .mdi(name, background, foreground): - iconKey = "\(name)|\(background.hexDescription())|\(foreground.hexDescription())" + 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 data - /// isn't a decodable image — caller falls back to the raw data. + /// 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 @@ -158,9 +167,9 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica /// 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(data: Data, maxDimension: CGFloat) -> Data? { + private static func downsample(url: URL, maxDimension: CGFloat) -> Data? { let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary - guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil } + guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { return nil } let downsampleOptions = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceShouldCacheImmediately: true, @@ -176,19 +185,4 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica return nil #endif } - - /// Wrap original-PNG-or-JPEG bytes in an `INImage`, downsampling first when possible. - /// Falls back to handing the raw bytes to `INImage` if ImageIO can't decode them. - private static func image(fromOriginalData data: Data) -> INImage { - INImage(imageData: downsample(data: data, maxDimension: 256) ?? data) - } -} - -private extension UIColor { - /// Stable hex serialization used only for conversation-ID construction. - func hexDescription() -> String { - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - getRed(&r, green: &g, blue: &b, alpha: &a) - return String(format: "#%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255)) - } } diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift index ebed38ff59..960415342d 100644 --- a/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift +++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift @@ -15,7 +15,13 @@ public struct NotificationSenderInfo: Equatable { /// 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) + case mdi( + name: String, + background: UIColor, + foreground: UIColor, + colorString: String?, + iconColorString: String? + ) } public let source: Source diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift b/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift index 7ae34920f0..bc98482b46 100644 --- a/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift +++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift @@ -21,12 +21,20 @@ public enum NotificationSenderParser { } if let mdiName = userInfo["notification_icon"] as? String, !mdiName.isEmpty { - let background = (userInfo["color"] as? String).flatMap(Self.color(fromHex:)) + let colorString = userInfo["color"] as? String + let iconColorString = userInfo["notification_icon_color"] as? String + let background = colorString.flatMap(Self.color(fromHex:)) ?? AppConstants.tintColor - let foreground = (userInfo["notification_icon_color"] as? String).flatMap(Self.color(fromHex:)) + let foreground = iconColorString.flatMap(Self.color(fromHex:)) ?? .white return NotificationSenderInfo( - source: .mdi(name: mdiName, background: background, foreground: foreground), + source: .mdi( + name: mdiName, + background: background, + foreground: foreground, + colorString: colorString, + iconColorString: iconColorString + ), senderName: senderName ) } diff --git a/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift index d3ddc6da06..149ce9cf91 100644 --- a/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift +++ b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift @@ -18,6 +18,11 @@ final class NotificationCommunicationDecoratorTests: XCTestCase { 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 @@ -29,7 +34,13 @@ final class NotificationCommunicationDecoratorTests: XCTestCase { func testBuildIntent_mdi_setsSenderNameAndImage() throws { let info = NotificationSenderInfo( - source: .mdi(name: "mdi:door", background: .red, foreground: .white), + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: "#FF0000", + iconColorString: "#FFFFFF" + ), senderName: "Front Door" ) let intent = try hang(Promise(decorator.buildIntent( @@ -47,7 +58,13 @@ final class NotificationCommunicationDecoratorTests: XCTestCase { func testBuildIntent_conversationIdentifier_stableAcrossCalls() throws { let info = NotificationSenderInfo( - source: .mdi(name: "mdi:door", background: .red, foreground: .white), + 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))) @@ -58,11 +75,23 @@ final class NotificationCommunicationDecoratorTests: XCTestCase { func testBuildIntent_conversationIdentifier_differsForDifferentSenderNames() throws { let a = NotificationSenderInfo( - source: .mdi(name: "mdi:door", background: .red, foreground: .white), + 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), + 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))) @@ -73,7 +102,13 @@ final class NotificationCommunicationDecoratorTests: XCTestCase { func testDecorate_emptyTitle_returnsOriginalContentUnchanged() throws { let original = content(title: "", body: "x") let info = NotificationSenderInfo( - source: .mdi(name: "mdi:door", background: .red, foreground: .white), + 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))) diff --git a/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift b/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift index 56ddf4da19..15819392e3 100644 --- a/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift +++ b/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift @@ -28,13 +28,26 @@ final class NotificationIconCacheTests: XCTestCase { XCTAssertEqual(cache.data(forKey: "abc"), payload) } - func testEviction_dropsOldestWhenOverLimit() { + func testEviction_dropsOldestWhenOverLimit() throws { cache.setData(Data([1]), forKey: "k1") - Thread.sleep(forTimeInterval: 0.01) // ensure distinct mtimes cache.setData(Data([2]), forKey: "k2") - Thread.sleep(forTimeInterval: 0.01) cache.setData(Data([3]), forKey: "k3") - Thread.sleep(forTimeInterval: 0.01) + + 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") diff --git a/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift b/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift index f3d474d222..889ff51ac0 100644 --- a/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift +++ b/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift @@ -5,11 +5,23 @@ import XCTest final class NotificationSenderInfoTests: XCTestCase { func testEquatable_sameValues_areEqual() { let a = NotificationSenderInfo( - source: .mdi(name: "mdi:door", background: .red, foreground: .white), + 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), + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: nil, + iconColorString: nil + ), senderName: "Front Door" ) XCTAssertEqual(a, b) @@ -17,11 +29,23 @@ final class NotificationSenderInfoTests: XCTestCase { func testEquatable_differentSenderName_areNotEqual() { let a = NotificationSenderInfo( - source: .mdi(name: "mdi:door", background: .red, foreground: .white), + 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), + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: nil, + iconColorString: nil + ), senderName: "Back Door" ) XCTAssertNotEqual(a, b) From 1b7f5d87f561c752fafbd453fe0488e38ba10838 Mon Sep 17 00:00:00 2001 From: Roei Bracha Date: Thu, 28 May 2026 19:24:08 +0300 Subject: [PATCH 3/8] Wrap CarPlay assist session actionButtons configuration in compiler check --- .../Templates/QuickAccess/CarPlayAssistSession.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift index a309e29e34..970980323c 100644 --- a/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift +++ b/Sources/CarPlay/Templates/QuickAccess/CarPlayAssistSession.swift @@ -54,6 +54,7 @@ final class CarPlayAssistSession: NSObject { private let prompt: String? private lazy var template: CPVoiceControlTemplate = { + #if compiler(>=6.3) let recordButton = CPButton( image: makeActionButtonImage(icon: .microphoneIcon, color: .haPrimary) ) { [weak self] _ in @@ -73,6 +74,7 @@ final class CarPlayAssistSession: NSObject { let actionButtons: [CPButton] = promptToSend == nil ? [recordButton, helpButton] : [recordButton, replayPromptButton, helpButton] + #endif let idleState = CPVoiceControlState( identifier: VoiceControlStateID.idle.rawValue, @@ -83,7 +85,9 @@ final class CarPlayAssistSession: NSObject { ), repeats: false ) + #if compiler(>=6.3) idleState.actionButtons = actionButtons + #endif let recordingState = CPVoiceControlState( identifier: VoiceControlStateID.recording.rawValue, @@ -112,7 +116,9 @@ final class CarPlayAssistSession: NSObject { image: MaterialDesignIcons.alertCircleIcon.carPlayIcon(color: .systemRed, context: .assistStateIndicator), repeats: false ) + #if compiler(>=6.3) errorState.actionButtons = actionButtons + #endif return CPVoiceControlTemplate( voiceControlStates: [recordingState, processingState, respondingState, idleState, errorState] From f015944c262c3967871c77ce41fb72c81679acda Mon Sep 17 00:00:00 2001 From: Roei Bracha Date: Thu, 28 May 2026 19:47:19 +0300 Subject: [PATCH 4/8] Support notification decoration without API context for local testing --- .../NotificationService/NotificationService.swift | 8 +++++++- .../NotificationCommunicationDecorator.swift | 12 ++++++++---- .../NotificationSenderParserTests.swift | 10 +++++----- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/Sources/Extensions/NotificationService/NotificationService.swift b/Sources/Extensions/NotificationService/NotificationService.swift index 9525b8cad3..3dfb8d7344 100644 --- a/Sources/Extensions/NotificationService/NotificationService.swift +++ b/Sources/Extensions/NotificationService/NotificationService.swift @@ -11,7 +11,13 @@ final class NotificationService: UNNotificationServiceExtension { guard let server = Current.servers.server(for: request.content), let api = Current.api(for: server) else { - contentHandler(request.content) + if let sender = NotificationSenderParser.parse(from: request.content) { + Current.notificationCommunicationDecorator + .decorate(content: request.content, sender: sender, api: nil) + .done { contentHandler($0) } + } else { + contentHandler(request.content) + } return } diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift index 22353d128e..67444e819b 100644 --- a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift +++ b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift @@ -9,7 +9,7 @@ public protocol NotificationCommunicationDecorator { func decorate( content: UNNotificationContent, sender: NotificationSenderInfo, - api: HomeAssistantAPI + api: HomeAssistantAPI? ) -> Guarantee } @@ -27,7 +27,7 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica public func decorate( content: UNNotificationContent, sender: NotificationSenderInfo, - api: HomeAssistantAPI + api: HomeAssistantAPI? ) -> Guarantee { let title = content.title guard !title.isEmpty else { return .value(content) } @@ -49,7 +49,7 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica sender: NotificationSenderInfo, title: String, body: String, - api: HomeAssistantAPI + api: HomeAssistantAPI? ) -> Guarantee { avatarImage(for: sender.source, api: api).map { [self] image in let conversationID = conversationIdentifier(for: sender) @@ -91,7 +91,7 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica /// MDI path is synchronous (no network). URL path downloads, caches, and downsamples the avatar. private func avatarImage( for source: NotificationSenderInfo.Source, - api: HomeAssistantAPI + api: HomeAssistantAPI? ) -> Guarantee { switch source { case let .mdi(name, background, foreground, _, _): @@ -110,6 +110,10 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica 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 { diff --git a/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift b/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift index 52b8b082f2..9333259120 100644 --- a/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift +++ b/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift @@ -47,12 +47,12 @@ final class NotificationSenderParserTests: XCTestCase { let parsed = NotificationSenderParser.parse(from: content(userInfo: [ "notification_icon": "mdi:door", ])) - guard case let .mdi(name, background, foreground) = parsed?.source else { + 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, .white) + XCTAssertEqual(foreground, UIColor.white) XCTAssertEqual(parsed?.senderName, "Hi") } @@ -61,7 +61,7 @@ final class NotificationSenderParserTests: XCTestCase { "notification_icon": "mdi:door", "color": "#2196F3", ])) - guard case let .mdi(_, background, _) = parsed?.source else { return XCTFail() } + guard case let .mdi(_, background, _, _, _) = parsed?.source else { return XCTFail() } XCTAssertEqual(background, UIColor(hex: "#2196F3")) } @@ -70,7 +70,7 @@ final class NotificationSenderParserTests: XCTestCase { "notification_icon": "mdi:door", "notification_icon_color": "#FF5722", ])) - guard case let .mdi(_, _, foreground) = parsed?.source else { return XCTFail() } + guard case let .mdi(_, _, foreground, _, _) = parsed?.source else { return XCTFail() } XCTAssertEqual(foreground, UIColor(hex: "#FF5722")) } @@ -79,7 +79,7 @@ final class NotificationSenderParserTests: XCTestCase { "notification_icon": "mdi:door", "color": "not-a-color", ])) - guard case let .mdi(_, background, _) = parsed?.source else { return XCTFail() } + guard case let .mdi(_, background, _, _, _) = parsed?.source else { return XCTFail() } assertIsTintColor(background) } From 0273aed6e63b7c97fbfe01b1e874737bd0204e56 Mon Sep 17 00:00:00 2001 From: Roei Bracha Date: Thu, 28 May 2026 19:59:22 +0300 Subject: [PATCH 5/8] Add Communication Notifications entitlement to extensions --- Configuration/Entitlements/Extension-catalyst.entitlements | 2 ++ Configuration/Entitlements/Extension-ios.entitlements | 2 ++ 2 files changed, 4 insertions(+) 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) From 5a0c9e28e6e487d3966ea5ba32ca0bff02389a12 Mon Sep 17 00:00:00 2001 From: Roei Bracha Date: Thu, 28 May 2026 20:22:12 +0300 Subject: [PATCH 6/8] Decorate local push notifications in LocalPushManager --- .../LocalPush/LocalPushManager.swift | 6 ++ Tests/Shared/LocalPushManager.test.swift | 61 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift b/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift index a87ea07cde..25823ee5f6 100644 --- a/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift +++ b/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift @@ -181,6 +181,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 Current.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/Tests/Shared/LocalPushManager.test.swift b/Tests/Shared/LocalPushManager.test.swift index d63dc91d69..05dc8b5fde 100644 --- a/Tests/Shared/LocalPushManager.test.swift +++ b/Tests/Shared/LocalPushManager.test.swift @@ -381,6 +381,67 @@ 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() + let originalDecorator = Current.notificationCommunicationDecorator + Current.notificationCommunicationDecorator = spy + defer { + Current.notificationCommunicationDecorator = originalDecorator + } + + setUpManager(webhookID: "webhook1") + + 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 { From f18784e385c384ccfde6bc316a3ed1c5671d8a43 Mon Sep 17 00:00:00 2001 From: Roei Bracha Date: Thu, 28 May 2026 22:27:12 +0300 Subject: [PATCH 7/8] feat: support custom notification icons via local push and APNs - Support 'notification_icon', 'notification_icon_color', and 'color' keys in the push payload. - Promote custom notification icon fields in local push / websocket parser. - Register 'INSendMessageIntent' in NotificationService Extension Info.plist to support communication notification decoration. - Ensure notification intent donation completes before finishing decoration. - Add unit tests for local push event, notification sender parser, and legacy parser. --- .../NotificationService/Resources/Info.plist | 11 +++++++ .../Sources/NotificationParserLegacy.swift | 9 +++++ .../notification_icon.json | 33 +++++++++++++++++++ .../NotificationCommunicationDecorator.swift | 19 +++++------ .../NotificationSenderParser.swift | 13 +++++--- .../Sources/NotificationParserLegacy.swift | 9 +++++ Tests/Shared/LocalPushEvent.test.swift | 20 +++++++++++ .../NotificationSenderParserTests.swift | 15 +++++++++ 8 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/notification_icon.json 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..e15d0b15c6 100644 --- a/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift @@ -160,6 +160,15 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { addAttachment(key: "image", contentType: "jpeg") addAttachment(key: "audio", contentType: "waveformaudio") + for key in ["icon_url", "notification_icon", "notification_icon_color", "color"] { + if let value = data[key] { + payload[key] = value + } + } + if payload["icon_url"] != nil || payload["notification_icon"] != nil { + needsMutableContent = true + } + payload["url"] = data["url"] payload["shortcut"] = data["shortcut"] payload["presentation_options"] = data["presentation_options"] 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/NotificationSender/NotificationCommunicationDecorator.swift b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift index 67444e819b..25d1a42637 100644 --- a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift +++ b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift @@ -51,7 +51,7 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica body: String, api: HomeAssistantAPI? ) -> Guarantee { - avatarImage(for: sender.source, api: api).map { [self] image in + 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() @@ -74,17 +74,14 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica sender: person, attachments: nil ) - // Donate before returning so that `decorate`'s subsequent call to - // `content.updating(from:)` can associate this notification with the - // conversation. Donation is a global system side-effect (visible in Siri - // suggestions); failures here are logged but never block notification - // delivery, since the styling still applies without the donation. - let interaction = INInteraction(intent: intent, response: nil) - interaction.direction = .incoming - interaction.donate { error in - if let error { Current.Log.error("INInteraction donate failed: \(error)") } + return 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) + } } - return intent } } diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift b/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift index bc98482b46..3845ba1b7c 100644 --- a/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift +++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift @@ -9,9 +9,14 @@ public enum NotificationSenderParser { 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 = userInfo["icon_url"] as? String, + if let urlString = value(forKey: "icon_url") as? String, !urlString.isEmpty, let url = URL(string: urlString) { return NotificationSenderInfo( @@ -20,9 +25,9 @@ public enum NotificationSenderParser { ) } - if let mdiName = userInfo["notification_icon"] as? String, !mdiName.isEmpty { - let colorString = userInfo["color"] as? String - let iconColorString = userInfo["notification_icon_color"] as? String + 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:)) diff --git a/Sources/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/SharedPush/Sources/NotificationParserLegacy.swift index 58be8ff010..33adc3723b 100644 --- a/Sources/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/SharedPush/Sources/NotificationParserLegacy.swift @@ -160,6 +160,15 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { addAttachment(key: "image", contentType: "jpeg") addAttachment(key: "audio", contentType: "waveformaudio") + for key in ["icon_url", "notification_icon", "notification_icon_color", "color"] { + if let value = data[key] { + payload[key] = value + } + } + if payload["icon_url"] != nil || payload["notification_icon"] != nil { + needsMutableContent = true + } + payload["url"] = data["url"] payload["shortcut"] = data["shortcut"] payload["presentation_options"] = data["presentation_options"] 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/NotificationSender/NotificationSenderParserTests.swift b/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift index 9333259120..782682d3b4 100644 --- a/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift +++ b/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift @@ -56,6 +56,21 @@ final class NotificationSenderParserTests: XCTestCase { 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", From 09612bec6e46e9bb7248471787b975b038326ec3 Mon Sep 17 00:00:00 2001 From: Roei Bracha Date: Thu, 28 May 2026 22:41:52 +0300 Subject: [PATCH 8/8] fix: address PR review comments for Equatable conformance and relative URL cache key collision --- .../NotificationCommunicationDecorator.swift | 3 ++- .../NotificationIconCache.swift | 13 ++++++++----- .../NotificationSenderInfo.swift | 18 ++++++++++++++++++ ...tificationCommunicationDecoratorTests.swift | 4 ++-- .../NotificationIconCacheTests.swift | 10 ++++++++++ 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift index 25d1a42637..582afad751 100644 --- a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift +++ b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift @@ -103,7 +103,8 @@ public final class NotificationCommunicationDecoratorImpl: NotificationCommunica return .value(nil) #endif case let .iconURL(url, needsAuth): - let cacheKey = notificationIconCacheKey(for: url) + 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)) } diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift b/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift index 189c89dae0..af5c5c2eb5 100644 --- a/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift +++ b/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift @@ -10,11 +10,14 @@ public protocol NotificationIconCache { func setData(_ data: Data, forKey key: String) } -/// Canonical cache key for a notification-icon URL. SHA-256 of the absolute URL -/// string with a `.img` suffix. Lives at module scope so callers don't need to -/// reference any concrete cache implementation. -public func notificationIconCacheKey(for url: URL) -> String { - let digest = SHA256.hash(data: Data(url.absoluteString.utf8)) +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" } diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift index 960415342d..4fe82d78c6 100644 --- a/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift +++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift @@ -22,6 +22,24 @@ public struct NotificationSenderInfo: Equatable { 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 diff --git a/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift index 149ce9cf91..3578e1fda4 100644 --- a/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift +++ b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift @@ -120,7 +120,7 @@ final class NotificationCommunicationDecoratorTests: XCTestCase { 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)) + cache.setData(pngBytes, forKey: notificationIconCacheKey(for: url, serverID: api.server.identifier.rawValue)) let info = NotificationSenderInfo( source: .iconURL(url, needsAuth: false), @@ -152,7 +152,7 @@ final class NotificationCommunicationDecoratorTests: XCTestCase { ))) XCTAssertNotNil(intent.sender?.image) XCTAssertNotNil( - cache.data(forKey: notificationIconCacheKey(for: url)), + cache.data(forKey: notificationIconCacheKey(for: url, serverID: api.server.identifier.rawValue)), "after download, the image must be cached" ) } diff --git a/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift b/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift index 15819392e3..c4135029e8 100644 --- a/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift +++ b/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift @@ -65,4 +65,14 @@ final class NotificationIconCacheTests: XCTestCase { 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) + } }