diff --git a/Configuration/Entitlements/Extension-catalyst.entitlements b/Configuration/Entitlements/Extension-catalyst.entitlements index 1c5ab6282e..44233c8274 100644 --- a/Configuration/Entitlements/Extension-catalyst.entitlements +++ b/Configuration/Entitlements/Extension-catalyst.entitlements @@ -10,6 +10,8 @@ com.apple.security.network.client + com.apple.developer.usernotifications.communication + keychain-access-groups $(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).HomeAssistant$(BUNDLE_ID_SUFFIX) diff --git a/Configuration/Entitlements/Extension-ios.entitlements b/Configuration/Entitlements/Extension-ios.entitlements index 5f83cb6ef4..5f765962b0 100644 --- a/Configuration/Entitlements/Extension-ios.entitlements +++ b/Configuration/Entitlements/Extension-ios.entitlements @@ -8,6 +8,8 @@ group.$(BUNDLE_ID_PREFIX).homeassistant$(BUNDLE_ID_SUFFIX) + com.apple.developer.usernotifications.communication + keychain-access-groups $(AppIdentifierPrefix)$(BUNDLE_ID_PREFIX).HomeAssistant$(BUNDLE_ID_SUFFIX) diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index a5641444eb..e8cab52a1a 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -25,7 +25,9 @@ /* Begin PBXBuildFile section */ 00C267608DCC88B783816CBF /* FanIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6888525DCF492642BA7EA3 /* FanIntent.swift */; }; 01783C74005B40978CE7508B /* NotificationIdentifierField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99EC7EF1136575D0E7A17091 /* NotificationIdentifierField.swift */; }; + 049D93E73DA5C3F4DDCAE78B /* NotificationSenderInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BAFAF531E869C27ED7EED78 /* NotificationSenderInfo.swift */; }; 072BACCC5B2509E4AF06BFED /* KioskSettingsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14444A34DA125693568C7035 /* KioskSettingsTable.swift */; }; + 0ABDE87C08B601388030C246 /* NotificationSenderParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDA89DB692DBB2B15C6582F /* NotificationSenderParser.swift */; }; 0C1F82B6DF9051F98A587352 /* ComplicationEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3995EF7323087AA35F01BDDF /* ComplicationEditView.swift */; }; 0907D6244565287ED4E46A3C /* WhatsNewCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09953104F4749B10268B7C61 /* WhatsNewCatalog.swift */; }; 5002BDAF2FEE000100BEEF01 /* WhatsNewEngine.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5002BDAE2FEE000100BEEF01 /* WhatsNewEngine.test.swift */; }; @@ -468,6 +470,8 @@ 2F50FC61669812D485E608EC /* Pods-iOS-Extensions-PushProvider-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */; }; 324A8B0F152A80EA190D98F3 /* Pods_iOS_Extensions_Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC9B77AAC44845DC9EE48759 /* Pods_iOS_Extensions_Intents.framework */; }; 37414CBA81F74D6AADCFE442 /* WidgetCommonlyUsedEntitiesTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF91E383A44843F087423FB5 /* WidgetCommonlyUsedEntitiesTimelineProvider.swift */; }; + 37F03845BDFCD4EC98483F13 /* NotificationSenderInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BAFAF531E869C27ED7EED78 /* NotificationSenderInfo.swift */; }; + 38563FD894F79F24705C5455 /* NotificationIconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49AED9BCF91ADD4EBBC025D4 /* NotificationIconCache.swift */; }; 38A4EBA18ADEEE555AD14F52 /* Pods-iOS-App-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */; }; 3997926A2B7F904A00231B54 /* MobileAppConfigPushCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792692B7F904A00231B54 /* MobileAppConfigPushCategory.swift */; }; 3997926B2B7F904A00231B54 /* MobileAppConfigPushCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399792692B7F904A00231B54 /* MobileAppConfigPushCategory.swift */; }; @@ -957,7 +961,6 @@ 42A47A8A2C452DB500C9B43D /* MockWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A47A892C452DB500C9B43D /* MockWebViewController.swift */; }; 42A47A902C4548E100C9B43D /* ImprovDiscoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A47A8F2C4548E100C9B43D /* ImprovDiscoverView.swift */; }; 42A7F1012FBC000100BEEF01 /* AllowedTag.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1002FBC000100BEEF01 /* AllowedTag.test.swift */; }; - 42A7F1012FBC000100BEEF01 /* Database/AllowedTag.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1002FBC000100BEEF01 /* Database/AllowedTag.test.swift */; }; 42A7F1212FBC000100BEEF01 /* NFCTagApproval.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A7F1202FBC000100BEEF01 /* NFCTagApproval.test.swift */; }; 42A818E02BBEA8150083D045 /* AssistViewModel.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A818DF2BBEA8150083D045 /* AssistViewModel.test.swift */; }; 42A818E32BBEA9780083D045 /* MockAudioRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42A818E22BBEA9780083D045 /* MockAudioRecorder.swift */; }; @@ -1255,10 +1258,11 @@ 5F2ECF3C2CA505A59A1CFFAF /* Pods_iOS_SharedTesting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65B4DC669522BB7A70C5EED0 /* Pods_iOS_SharedTesting.framework */; }; 61495A70232316478717CF27 /* Pods_iOS_Shared_iOS_Tests_Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FF3A67FB1C2B548C6C7730C /* Pods_iOS_Shared_iOS_Tests_Shared.framework */; }; 618FCE5CA6B34267BB2056F5 /* MockLiveActivityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */; }; + 647725908C85F30DDEBB76B8 /* NotificationSenderParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DDA89DB692DBB2B15C6582F /* NotificationSenderParser.swift */; }; 651755E378F6F79AB401F05C /* AssistPipelineAddList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07701F2786F6D45E945CC1AA /* AssistPipelineAddList.swift */; }; 65286F3B745551AD4090EE6B /* Pods-iOS-SharedTesting-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 4053903E4C54A6803204286E /* Pods-iOS-SharedTesting-metadata.plist */; }; 6596FA74E1A501276EA62D86 /* Pods_watchOS_Shared_watchOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD370D44DFFB906B05C3EB3A /* Pods_watchOS_Shared_watchOS.framework */; }; - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */; }; + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */; }; 6FCEBAA2C8E9C5403055E73D /* IntentFanEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5E2F9F8F008EEA30C533FD /* IntentFanEntity.swift */; }; 70BD8A8EA1ABC5DC1F0A0D6E /* Pods_iOS_Shared_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C663B0750E0318469E7008C3 /* Pods_iOS_Shared_iOS.framework */; }; 71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */; }; @@ -1266,14 +1270,17 @@ 84F7755EFB03C3F463292ABF /* Pods-watchOS-Shared-watchOS-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */; }; 864FFB19D2954B698B31E350 /* WebViewController+ReAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1945E8E386664E5A88668E77 /* WebViewController+ReAuth.swift */; }; 87ADC61008345747CABC2270 /* KioskSettingsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14444A34DA125693568C7035 /* KioskSettingsTable.swift */; }; + 8933A8C4AF839B6124528F4E /* NotificationCommunicationDecoratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6489140FB77123E7A3208A49 /* NotificationCommunicationDecoratorTests.swift */; }; 897D7538631C46D4BD849CF5 /* NotificationsCommandManagerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */; }; 8DFA3DEE4881E59961C3B5E2 /* BarometerSensor.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07A4CD40BADC119045547D77 /* BarometerSensor.test.swift */; }; 8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; }; + 912FB683381E0CF42037596E /* NotificationCommunicationDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3EF62803035779DE2513F2 /* NotificationCommunicationDecorator.swift */; }; 925D92FC9E2221189D67B22C /* ComplicationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FF9C3A30E10A8E214623EBB /* ComplicationListView.swift */; }; + 97271CFAB2E8E4010631DB25 /* NotificationCommunicationDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3EF62803035779DE2513F2 /* NotificationCommunicationDecorator.swift */; }; 999549244371450BC98C700E /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */; }; 9D57ECBD5431BC00BDC16F1E /* NotificationActionEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F913E441276235B7A2D7B29 /* NotificationActionEditorView.swift */; }; A1619F1ED93FB8B0E7E53C38 /* KioskLifecycleBrightness.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373AE72CB925F044BAE18B62 /* KioskLifecycleBrightness.test.swift */; }; - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */; }; + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */; }; A596C4D1E125E6863C7D2034 /* ComplicationEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512A72C5F1BCC979E74F7629 /* ComplicationEditViewModel.swift */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; A60E917B401A6D456F1DB630 /* ComplicationFamilySelectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1861EB0361816DC9260D1F5E /* ComplicationFamilySelectView.swift */; }; @@ -1537,9 +1544,11 @@ B6DDF8534A4176416CFAC79A /* KioskSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49767602CA2066683EC638F /* KioskSettingsView.swift */; }; B6E2D4D52270706300446DFA /* ha-loading.json in Resources */ = {isa = PBXBuildFile; fileRef = B6E2D4D42270706200446DFA /* ha-loading.json */; }; B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; + BA1AE9960FDBD2197E7F72C5 /* NotificationIconCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7303AC356A2589F690B6CC /* NotificationIconCacheTests.swift */; }; BB77559927344584B2C0E987 /* OnboardingAuthError.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */; }; + BCBFB6DBB37D6C1036FB7C4D /* NotificationSenderInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32795036FA9992877C8BFF76 /* NotificationSenderInfoTests.swift */; }; BD1044995DE13A04C0FA039A /* Pods_iOS_Extensions_Widgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D9C81015FD7A8FA8716E4F2 /* Pods_iOS_Extensions_Widgets.framework */; }; - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */; }; + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */; }; C10D762EFE08D347D0538339 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */; }; C1AE883A374C598B5BCCAE23 /* CustomWidgetIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7FF77B68D19A4AD68F8FE3 /* CustomWidgetIntentHelper.swift */; }; C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; }; @@ -1549,6 +1558,7 @@ CA0CA15000000000000000A2 /* LocationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0CA15000000000000000A1 /* LocationSettingsView.swift */; }; CA0CA15000000000000000B2 /* LocationSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0CA15000000000000000B1 /* LocationSettingsViewModel.swift */; }; CA6886D02384DA18A91F37DD /* Pods-iOS-Extensions-Intents-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */; }; + CB45D5CABCCA75A0DCB9047B /* NotificationIconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49AED9BCF91ADD4EBBC025D4 /* NotificationIconCache.swift */; }; CB4D44CC6DBA5176155E157E /* KioskSecretExitGestureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE1C6F8FA2181C936758465 /* KioskSecretExitGestureView.swift */; }; D014EEA92128E192008EA6F5 /* ConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D014EEA82128E192008EA6F5 /* ConnectionInfo.swift */; }; D03D892920E0A85300D4F28D /* Shared.h in Headers */ = {isa = PBXBuildFile; fileRef = D03D891920E0A85300D4F28D /* Shared.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1594,10 +1604,10 @@ D9A6697AF4D05BB8DE822A54 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */; }; D9BF1EFF40733A4A1D03B9C8 /* CustomWidgetIntentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A7FF77B68D19A4AD68F8FE3 /* CustomWidgetIntentHelper.swift */; }; DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */; }; - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */; }; DB54626ADCE0C32094C8C0B9 /* LoadingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */; }; DEFBE1A5E9A005B0A5392D27 /* KioskLocalization.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */; }; E3A02409794174F002C8BB4F /* IconSearchPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC23DE131CA8813C2DBD657 /* IconSearchPicker.swift */; }; + E7060E233474B57828DA3BBC /* NotificationSenderParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6E232706807425E1E56720 /* NotificationSenderParserTests.swift */; }; E92E09E3A93650D56E3C5093 /* KioskScreensaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */; }; F0D1DD41A8F55F6D767EBF37 /* TemplatePreviewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332C060487B468055C487070 /* TemplatePreviewSection.swift */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; @@ -2261,7 +2271,9 @@ 273BF4150C522259C6183B32 /* Pods-watchOS-Shared-watchOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.debug.xcconfig"; sourceTree = ""; }; 2A674A3ACB31BFCEB70423FE /* Pods-watchOS-WatchExtension-Watch.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.release.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.release.xcconfig"; sourceTree = ""; }; 2C50B9249EE482AF53672D36 /* Pods-iOS-SharedTesting.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-SharedTesting.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-SharedTesting/Pods-iOS-SharedTesting.beta.xcconfig"; sourceTree = ""; }; + 2C7303AC356A2589F690B6CC /* NotificationIconCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationIconCacheTests.swift; sourceTree = ""; }; 2F5A1F2C65420338C176B7BD /* CameraZoomGestureOverlay.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CameraZoomGestureOverlay.swift; sourceTree = ""; }; + 32795036FA9992877C8BFF76 /* NotificationSenderInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSenderInfoTests.swift; sourceTree = ""; }; 332C060487B468055C487070 /* TemplatePreviewSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TemplatePreviewSection.swift; sourceTree = ""; }; 33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Share.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 365F0937BF33147F29CE8F8B /* Pods-watchOS-Shared-watchOS.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-Shared-watchOS.beta.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-Shared-watchOS/Pods-watchOS-Shared-watchOS.beta.xcconfig"; sourceTree = ""; }; @@ -2849,7 +2861,6 @@ 42A7474A2E832FB8005E0332 /* Snapfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Snapfile; sourceTree = ""; }; 42A7474B2E832FB8005E0332 /* SnapshotHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; }; 42A7F1002FBC000100BEEF01 /* AllowedTag.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/AllowedTag.test.swift; sourceTree = ""; }; - 42A7F1002FBC000100BEEF01 /* Database/AllowedTag.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/AllowedTag.test.swift; sourceTree = ""; }; 42A7F1202FBC000100BEEF01 /* NFCTagApproval.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFCTagApproval.test.swift; sourceTree = ""; }; 42A818DF2BBEA8150083D045 /* AssistViewModel.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssistViewModel.test.swift; sourceTree = ""; }; 42A818E22BBEA9780083D045 /* MockAudioRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAudioRecorder.swift; sourceTree = ""; }; @@ -3090,8 +3101,10 @@ 46F9A1002F0A000100ABCD01 /* StatusItemTitleRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItemTitleRendererTests.swift; sourceTree = ""; }; 480E9A5D40714BBAA81B15F7 /* ClientCertificate.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientCertificate.test.swift; sourceTree = ""; }; 491E98FE25D543560077BBE3 /* LogbookEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogbookEntry.swift; sourceTree = ""; }; + 49AED9BCF91ADD4EBBC025D4 /* NotificationIconCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationIconCache.swift; sourceTree = ""; }; 49BCC46A2F929005F9529BF9 /* Pods-watchOS-WatchExtension-Watch.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.beta.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.beta.xcconfig"; sourceTree = ""; }; 4A74A2EF6714F9B13C1DAFBD /* Pods-Tests-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.debug.xcconfig"; sourceTree = ""; }; + 4D6E232706807425E1E56720 /* NotificationSenderParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationSenderParserTests.swift; sourceTree = ""; }; 4F28982D819D8E105E6FB878 /* Pods-Tests-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.release.xcconfig"; sourceTree = ""; }; 4F4593A60DBF019E6C91AAA7 /* KioskLocalization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskLocalization.test.swift; sourceTree = ""; }; 504A2C852F8133E6002A3C0E /* CarPlayTabsSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlayTabsSelectionView.swift; sourceTree = ""; }; @@ -3100,17 +3113,19 @@ 553A33E097387AA44265DB13 /* Pods-iOS-App-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-App-metadata.plist"; path = "Pods/Pods-iOS-App-metadata.plist"; sourceTree = ""; }; 592EED7A6C2444872F11C17B /* Pods-iOS-Extensions-NotificationService-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-NotificationService-metadata.plist"; path = "Pods/Pods-iOS-Extensions-NotificationService-metadata.plist"; sourceTree = ""; }; 5BFD0D30836C69840AB63A8A /* LoadingButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LoadingButton.swift; sourceTree = ""; }; - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Database/GRDB+Initialization.test.swift"; sourceTree = ""; }; 5D4737412F241342009A70EA /* FolderDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderDetailView.swift; sourceTree = ""; }; 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLiveActivityRegistry.swift; sourceTree = ""; }; 5FF9C3A30E10A8E214623EBB /* ComplicationListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListView.swift; sourceTree = ""; }; 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_PushProvider.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskScreensaverViewController.swift; sourceTree = ""; }; + 6489140FB77123E7A3208A49 /* NotificationCommunicationDecoratorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCommunicationDecoratorTests.swift; sourceTree = ""; }; 6563AFB7BDAF57478CA18D9B /* Pods-iOS-Extensions-PushProvider.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.debug.xcconfig"; sourceTree = ""; }; 65B4DC669522BB7A70C5EED0 /* Pods_iOS_SharedTesting.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_SharedTesting.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6723A4E97E50C3C9141428D0 /* Pods-iOS-Extensions-Widgets-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Widgets-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Widgets-metadata.plist"; sourceTree = ""; }; 6A7FF77B68D19A4AD68F8FE3 /* CustomWidgetIntentHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CustomWidgetIntentHelper.swift; sourceTree = ""; }; 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-Shared-watchOS-metadata.plist"; path = "Pods/Pods-watchOS-Shared-watchOS-metadata.plist"; sourceTree = ""; }; + 6BAFAF531E869C27ED7EED78 /* NotificationSenderInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSenderInfo.swift; sourceTree = ""; }; 6BC23DE131CA8813C2DBD657 /* IconSearchPicker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IconSearchPicker.swift; sourceTree = ""; }; 6BECEB2525564358A124F818 /* FolderEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderEditView.swift; sourceTree = ""; }; 6D00E1755885575FF8118933 /* ControlFan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFan.swift; sourceTree = ""; }; @@ -3122,10 +3137,11 @@ 7AFA80D48C707A822CCB3F38 /* Pods-iOS-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.release.xcconfig"; sourceTree = ""; }; 7C3CCF89D04DB409DDFC4A09 /* Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared.release.xcconfig"; sourceTree = ""; }; 7DC07BDAC69AD95BDEFD8AFF /* Pods-iOS-Extensions-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationService.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationService/Pods-iOS-Extensions-NotificationService.release.xcconfig"; sourceTree = ""; }; + 7DDA89DB692DBB2B15C6582F /* NotificationSenderParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationSenderParser.swift; sourceTree = ""; }; 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskConstants.swift; sourceTree = ""; }; 862436CFE6E3F4B31500EFB2 /* ComplicationListViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComplicationListViewModel.swift; sourceTree = ""; }; 86BFD63671D2D0A012DFE169 /* Pods-iOS-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-App/Pods-iOS-App.debug.xcconfig"; sourceTree = ""; }; - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseMigration.test.swift; sourceTree = ""; }; 8A34A5417D650BBBE9D2D7C0 /* ControlFanValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlFanValueProvider.swift; sourceTree = ""; }; 8D6888525DCF492642BA7EA3 /* FanIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanIntent.swift; sourceTree = ""; }; 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-watchOS-WatchExtension-Watch-metadata.plist"; path = "Pods/Pods-watchOS-WatchExtension-Watch-metadata.plist"; sourceTree = ""; }; @@ -3138,7 +3154,7 @@ 9C4E5E27229D992A0044C8EC /* HomeAssistant.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = HomeAssistant.xcconfig; sourceTree = ""; }; 9D84964A844E6CD21F16D3AB /* Pods-watchOS-WatchExtension-Watch.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; path = "Pods/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch.debug.xcconfig"; sourceTree = ""; }; 9DA2D62699FC44A99AB37480 /* WatchFolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFolderRow.swift; sourceTree = ""; }; - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/TableSchemaTests.test.swift; sourceTree = ""; }; 9F913E441276235B7A2D7B29 /* NotificationActionEditorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationActionEditorView.swift; sourceTree = ""; }; 9F9398CFD66E4C66DC39E1D3 /* Pods-iOS-Extensions-PushProvider.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-PushProvider.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-PushProvider/Pods-iOS-Extensions-PushProvider.beta.xcconfig"; sourceTree = ""; }; A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAuthError.test.swift; sourceTree = ""; }; @@ -3149,6 +3165,7 @@ A95FDD112F6B8A3E008EF72F /* HALiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALiveActivityConfiguration.swift; sourceTree = ""; }; A95FDD122F6B8A3E008EF72F /* HALockScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALockScreenView.swift; sourceTree = ""; }; A95FDD172F6B8A5B008EF72F /* LiveActivitySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettingsView.swift; sourceTree = ""; }; + AA3EF62803035779DE2513F2 /* NotificationCommunicationDecorator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCommunicationDecorator.swift; sourceTree = ""; }; AA48C686F844D08C426A8D74 /* Pods_Tests_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Tests_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AC24B1CAB85767B8171BB850 /* Pods-iOS-Extensions-NotificationContent.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-NotificationContent.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-NotificationContent/Pods-iOS-Extensions-NotificationContent.release.xcconfig"; sourceTree = ""; }; AC720001000000000000AA01 /* ActionsSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionsSettingsView.swift; sourceTree = ""; }; @@ -3468,7 +3485,7 @@ B6FD0574228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; B7D8DAEFAD435091FDDD61E7 /* Pods_iOS_Extensions_NotificationContent.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_NotificationContent.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B833A17275EC47FA65A3235A /* YamlPreviewSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = YamlPreviewSection.swift; sourceTree = ""; }; - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database/DatabaseTableProtocol.test.swift; sourceTree = ""; }; BC9B77AAC44845DC9EE48759 /* Pods_iOS_Extensions_Intents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iOS_Extensions_Intents.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BDC6ACBDCC2C47510C37E4C8 /* NotificationCategoryListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationCategoryListView.swift; sourceTree = ""; }; BEF9A7008EFA4A6FC9E02B5E /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; }; @@ -3517,6 +3534,7 @@ D72C761F65606EF882E2A7B1 /* Pods-iOS-Extensions-Today-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Today-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Today-metadata.plist"; sourceTree = ""; }; D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCommandManagerLiveActivityTests.swift; sourceTree = ""; }; DEEEA3344F064DB183F46C47 /* WidgetCommonlyUsedEntities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetCommonlyUsedEntities.swift; sourceTree = ""; }; + E1AB8533E2EF222138FF0E38 /* communication_notification.apns */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = communication_notification.apns; sourceTree = ""; }; E3D5CF14402325076CA105EB /* Pods-iOS-Extensions-PushProvider-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-PushProvider-metadata.plist"; path = "Pods/Pods-iOS-Extensions-PushProvider-metadata.plist"; sourceTree = ""; }; E41A4AAEF642A72ACDB6C006 /* Pods-iOS-Extensions-Intents-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Extensions-Intents-metadata.plist"; path = "Pods/Pods-iOS-Extensions-Intents-metadata.plist"; sourceTree = ""; }; ED4B2D38DF1316D881D79769 /* Pods-iOS-Shared-iOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.debug.xcconfig"; sourceTree = ""; }; @@ -3743,6 +3761,7 @@ 110FB4512499DB3A000865B4 /* map_notification.apns */, 1100D52024974D6700B1073C /* camera_notification.apns */, 11169B3E262BCEE6005EF90A /* dynamic_notification.apns */, + E1AB8533E2EF222138FF0E38 /* communication_notification.apns */, ); path = TestNotifications; sourceTree = ""; @@ -6531,6 +6550,17 @@ path = ClientEvents; sourceTree = ""; }; + 4926A2CC8C41FFA25A4AEFED /* NotificationSender */ = { + isa = PBXGroup; + children = ( + 6BAFAF531E869C27ED7EED78 /* NotificationSenderInfo.swift */, + 7DDA89DB692DBB2B15C6582F /* NotificationSenderParser.swift */, + 49AED9BCF91ADD4EBBC025D4 /* NotificationIconCache.swift */, + AA3EF62803035779DE2513F2 /* NotificationCommunicationDecorator.swift */, + ); + path = NotificationSender; + sourceTree = ""; + }; 4C1A049B16335C08AECDAAC2 /* Screensaver */ = { isa = PBXGroup; children = ( @@ -7206,6 +7236,17 @@ path = Responses; sourceTree = ""; }; + B785E5717AD9D00AF1F15A2C /* NotificationSender */ = { + isa = PBXGroup; + children = ( + 32795036FA9992877C8BFF76 /* NotificationSenderInfoTests.swift */, + 4D6E232706807425E1E56720 /* NotificationSenderParserTests.swift */, + 2C7303AC356A2589F690B6CC /* NotificationIconCacheTests.swift */, + 6489140FB77123E7A3208A49 /* NotificationCommunicationDecoratorTests.swift */, + ); + path = NotificationSender; + sourceTree = ""; + }; CA0CA15000000000000000C1 /* Location */ = { isa = PBXGroup; children = ( @@ -7358,15 +7399,16 @@ 11CB98CC249E637300B05222 /* Version+HA.test.swift */, 11883CC424C12C8A0036A6C6 /* CLLocation+Extensions.test.swift */, 11883CC624C131EE0036A6C6 /* RealmZone.test.swift */, - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */, - 42A7F1002FBC000100BEEF01 /* Database/AllowedTag.test.swift */, - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */, - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */, - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */, + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */, + 42A7F1002FBC000100BEEF01 /* AllowedTag.test.swift */, + BC31518EE9DC9E065AC508D9 /* DatabaseTableProtocol.test.swift */, + 5C50FA39BF16AD0BD782D0D7 /* GRDB+Initialization.test.swift */, + 9EE9A0E08E6FEBDDE425D0D4 /* TableSchemaTests.test.swift */, 11EE9B4B24C5181A00404AF8 /* ModelManager.test.swift */, 11BC9E5424FDB88200B9FBF7 /* ActiveStateManager.test.swift */, 1104FCCE253275CF00B8BE34 /* WatchBackgroundRefreshScheduler.test.swift */, 11F2F28D2587285300F61F7C /* NotificationAttachment */, + B785E5717AD9D00AF1F15A2C /* NotificationSender */, 110AA57A25B38C02005061A0 /* ServerAlerter.test.swift */, 11267D0825BBA9FE00F28E5C /* Updater.test.swift */, 1133F5E425F1DBEA00AD776F /* CLLocation+Sanitize.test.swift */, @@ -7522,6 +7564,7 @@ 11ADF93D267D34A20040A7E3 /* NotificationCommands */, 11A3BD2B261921FC005237E6 /* LocalPush */, 11F2F21725871C1700F61F7C /* NotificationAttachments */, + 4926A2CC8C41FFA25A4AEFED /* NotificationSender */, 11169B9A262BE3E1005EF90A /* UNNotificationContent+Additions.swift */, ); path = Notifications; @@ -8250,7 +8293,7 @@ packageReferences = ( 420E64BB2D676B2400A31E86 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, 42B89EA62E05CC54000224A2 /* XCRemoteSwiftPackageReference "WebRTC" */, - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */, + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */, 4237E6372E5333370023B673 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 42B18FD52F38CA2300A1537A /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 42F384032FB49C9500390AFC /* XCRemoteSwiftPackageReference "DebugSwift" */, @@ -8779,14 +8822,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Tests-App/Pods-Tests-App-frameworks.sh\"\n"; @@ -8924,14 +8963,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-App/Pods-iOS-App-frameworks.sh\"\n"; @@ -8967,14 +9002,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-iOS-Shared-iOS-Tests-Shared/Pods-iOS-Shared-iOS-Tests-Shared-frameworks.sh\"\n"; @@ -9074,14 +9105,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-watchOS-WatchExtension-Watch/Pods-watchOS-WatchExtension-Watch-frameworks.sh\"\n"; @@ -10149,6 +10176,7 @@ 11AF4D20249C8AF1006C74C0 /* ConnectivitySensor.swift in Sources */, 42C0F7CA2D47936100BD5C76 /* StatePrecision.swift in Sources */, 11F2F27F258725D300F61F7C /* NotificationAttachmentErrorImage.swift in Sources */, + 37F03845BDFCD4EC98483F13 /* NotificationSenderInfo.swift in Sources */, B67CE8A622200F220034C1D0 /* HAAPI.swift in Sources */, 42B2637E2E16A1DC0042DF10 /* BaseColors.swift in Sources */, 1105CE1D272B9CB300F33BD8 /* ServerManager.swift in Sources */, @@ -10159,6 +10187,9 @@ 1104FC9225322C1800B8BE34 /* Dictionary+Additions.swift in Sources */, 118261F824F8D6B0000795C6 /* SensorProviderDependencies.swift in Sources */, 11C8E8AD24F36535003E7F89 /* DeviceWrapper.swift in Sources */, + 647725908C85F30DDEBB76B8 /* NotificationSenderParser.swift in Sources */, + CB45D5CABCCA75A0DCB9047B /* NotificationIconCache.swift in Sources */, + 912FB683381E0CF42037596E /* NotificationCommunicationDecorator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -10518,6 +10549,10 @@ 118261F724F8D6B0000795C6 /* SensorProviderDependencies.swift in Sources */, 11C8E8AE24F3778E003E7F89 /* DeviceWrapper.swift in Sources */, 87ADC61008345747CABC2270 /* KioskSettingsTable.swift in Sources */, + 049D93E73DA5C3F4DDCAE78B /* NotificationSenderInfo.swift in Sources */, + 0ABDE87C08B601388030C246 /* NotificationSenderParser.swift in Sources */, + 38563FD894F79F24705C5455 /* NotificationIconCache.swift in Sources */, + 97271CFAB2E8E4010631DB25 /* NotificationCommunicationDecorator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -10536,12 +10571,13 @@ 11EE9B4C24C5181A00404AF8 /* ModelManager.test.swift in Sources */, 11AF4D2C249D965C006C74C0 /* BatterySensor.test.swift in Sources */, 11F2F2B8258728B200F61F7C /* NotificationAttachmentParserURL.test.swift in Sources */, + BCBFB6DBB37D6C1036FB7C4D /* NotificationSenderInfoTests.swift in Sources */, 11883CC724C131EE0036A6C6 /* RealmZone.test.swift in Sources */, - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */, - 42A7F1012FBC000100BEEF01 /* Database/AllowedTag.test.swift in Sources */, - A2F3A140CDD1EF1AEA6DFAB9 /* Database/DatabaseTableProtocol.test.swift in Sources */, - 692BCBBA4EEEABCC76DBBECA /* Database/GRDB+Initialization.test.swift in Sources */, - BECCC152A4E3F69A8EF5A6F3 /* Database/TableSchemaTests.test.swift in Sources */, + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */, + 42A7F1012FBC000100BEEF01 /* AllowedTag.test.swift in Sources */, + A2F3A140CDD1EF1AEA6DFAB9 /* DatabaseTableProtocol.test.swift in Sources */, + 692BCBBA4EEEABCC76DBBECA /* GRDB+Initialization.test.swift in Sources */, + BECCC152A4E3F69A8EF5A6F3 /* TableSchemaTests.test.swift in Sources */, 11267D0925BBA9FE00F28E5C /* Updater.test.swift in Sources */, 11A3F08C24ECE88C0018D84F /* WebhookUpdateLocation.test.swift in Sources */, 42FDCA272F0C7EB900C92958 /* EntityRegistry.test.swift in Sources */, @@ -10590,6 +10626,9 @@ 618FCE5CA6B34267BB2056F5 /* MockLiveActivityRegistry.swift in Sources */, 71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */, 897D7538631C46D4BD849CF5 /* NotificationsCommandManagerLiveActivityTests.swift in Sources */, + E7060E233474B57828DA3BBC /* NotificationSenderParserTests.swift in Sources */, + BA1AE9960FDBD2197E7F72C5 /* NotificationIconCacheTests.swift in Sources */, + 8933A8C4AF839B6124528F4E /* NotificationCommunicationDecoratorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -12423,7 +12462,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = { + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = { isa = XCLocalSwiftPackageReference; relativePath = Sources/SharedPush; }; @@ -12495,7 +12534,7 @@ }; 4273F7DF2E258827000629F7 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; - package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */; + package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */; productName = SharedPush; }; 427692E22B98B82500F24321 /* SharedPush */ = { diff --git a/Sources/Extensions/NotificationContent/Resources/TestNotifications/communication_notification.apns b/Sources/Extensions/NotificationContent/Resources/TestNotifications/communication_notification.apns new file mode 100644 index 0000000000..316e40d8da --- /dev/null +++ b/Sources/Extensions/NotificationContent/Resources/TestNotifications/communication_notification.apns @@ -0,0 +1,14 @@ +{ + "aps": { + "alert": { + "title": "Dishwasher", + "body": "Cycle complete." + }, + "sound": "default", + "category": "notification", + "mutable-content": 1 + }, + "notification_icon": "mdi:dishwasher", + "color": "#4CAF50", + "webhook_id": "REPLACE_WITH_YOUR_WEBHOOK_ID" +} diff --git a/Sources/Extensions/NotificationService/NotificationService.swift b/Sources/Extensions/NotificationService/NotificationService.swift index aa5fdf04f2..8bee214bd6 100644 --- a/Sources/Extensions/NotificationService/NotificationService.swift +++ b/Sources/Extensions/NotificationService/NotificationService.swift @@ -3,31 +3,43 @@ import Shared import UserNotifications final class NotificationService: UNNotificationServiceExtension { + private let notificationCommunicationDecorator = NotificationCommunicationDecoratorImpl() + override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { Current.Log.info("didReceive \(request), user info \(request.content.userInfo)") - guard let server = Current.servers.server(for: request.content), let api = Current.api(for: server) else { - contentHandler(request.content) + guard let server = Current.servers.server(for: request.content), + let api = Current.api(for: server) else { + if let sender = NotificationSenderParser.parse(from: request.content) { + notificationCommunicationDecorator + .decorate(content: request.content, sender: sender, api: nil) + .done { contentHandler($0) } + } else { + contentHandler(request.content) + } return } firstly { Current.notificationAttachmentManager.content(from: request.content, api: api) - }.recover { error in + }.recover { error -> Guarantee in Current.Log.error("failed to get content, giving default: \(error)") return .value(request.content) + }.then { content -> Guarantee in + guard let sender = NotificationSenderParser.parse(from: content) else { + return .value(content) + } + return self.notificationCommunicationDecorator + .decorate(content: content, sender: sender, api: api) }.done { contentHandler($0) } } override func serviceExtensionTimeWillExpire() { - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, - // otherwise the original push payload will be used. Current.Log.warning("serviceExtensionTimeWillExpire") } } diff --git a/Sources/Extensions/NotificationService/Resources/Info.plist b/Sources/Extensions/NotificationService/Resources/Info.plist index b6189964ff..55b94fe35f 100644 --- a/Sources/Extensions/NotificationService/Resources/Info.plist +++ b/Sources/Extensions/NotificationService/Resources/Info.plist @@ -25,8 +25,19 @@ NSAllowsArbitraryLoads + NSUserActivityTypes + + INSendMessageIntent + NSExtension + NSExtensionAttributes + + IntentsSupported + + INSendMessageIntent + + NSExtensionPointIdentifier com.apple.usernotifications.service NSExtensionPrincipalClass diff --git a/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift index 1989e5b624..53970dad97 100644 --- a/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/PushServer/SharedPush/Sources/NotificationParserLegacy.swift @@ -160,6 +160,16 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { addAttachment(key: "image", contentType: "jpeg") addAttachment(key: "audio", contentType: "waveformaudio") + for key in NotificationDecorationPayloadKey.notificationDecorationKeys { + if let value = data[key.rawValue] { + payload[key.rawValue] = value + } + } + if payload[NotificationDecorationPayloadKey.iconURL.rawValue] != nil || + payload[NotificationDecorationPayloadKey.notificationIcon.rawValue] != nil { + needsMutableContent = true + } + payload["url"] = data["url"] payload["shortcut"] = data["shortcut"] payload["presentation_options"] = data["presentation_options"] @@ -257,6 +267,20 @@ enum LegacyNotificationCommandType: String { case updateWidgets = "update_widgets" } +enum NotificationDecorationPayloadKey: String, CaseIterable { + case iconURL = "icon_url" + case notificationIcon = "notification_icon" + case notificationIconColor = "notification_icon_color" + case color + + static let notificationDecorationKeys: [Self] = [ + .iconURL, + .notificationIcon, + .notificationIconColor, + .color, + ] +} + private extension Dictionary where Value == Any { mutating func mutate( _ key: Key, diff --git a/Sources/PushServer/Tests/SharedPushTests/NotificationParserLegacy.test.swift b/Sources/PushServer/Tests/SharedPushTests/NotificationParserLegacy.test.swift index 84d67eb197..004968b966 100644 --- a/Sources/PushServer/Tests/SharedPushTests/NotificationParserLegacy.test.swift +++ b/Sources/PushServer/Tests/SharedPushTests/NotificationParserLegacy.test.swift @@ -1,5 +1,5 @@ import Foundation -import SharedPush +@testable import SharedPush import XCTest class NotificationParserLegacyTests: XCTestCase { @@ -65,4 +65,11 @@ class NotificationParserLegacyTests: XCTestCase { XCTAssertEqual(resultString, expectedString, data.name) } } + + func testNotificationDecorationKeyRawValues() { + XCTAssertEqual(NotificationDecorationPayloadKey.iconURL.rawValue, "icon_url") + XCTAssertEqual(NotificationDecorationPayloadKey.notificationIcon.rawValue, "notification_icon") + XCTAssertEqual(NotificationDecorationPayloadKey.notificationIconColor.rawValue, "notification_icon_color") + XCTAssertEqual(NotificationDecorationPayloadKey.color.rawValue, "color") + } } diff --git a/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/notification_icon.json b/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/notification_icon.json new file mode 100644 index 0000000000..c79f39ffa2 --- /dev/null +++ b/Sources/PushServer/Tests/SharedPushTests/notification_test_cases.bundle/notification_icon.json @@ -0,0 +1,33 @@ +{ + "input": { + "message": "test", + "title": "Phone", + "data": { + "notification_icon": "mdi:cellphone", + "notification_icon_color": "#FFFFFF", + "color": "#03A9F4" + }, + "registration_info": { + "app_id": "io.robbie.HomeAssistant.dev", + "os_version": "10.15", + "app_version": "2021.5" + } + }, + "rate_limit": true, + "headers": { + "apns-push-type": "alert" + }, + "payload": { + "aps": { + "alert": { + "body": "test", + "title": "Phone" + }, + "mutable-content": true, + "sound": "default" + }, + "color": "#03A9F4", + "notification_icon": "mdi:cellphone", + "notification_icon_color": "#FFFFFF" + } +} diff --git a/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift b/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift index a87ea07cde..2525724e38 100644 --- a/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift +++ b/Sources/Shared/Notifications/LocalPush/LocalPushManager.swift @@ -12,6 +12,7 @@ public protocol LocalPushManagerDelegate: AnyObject { public class LocalPushManager { public let server: Server public weak var delegate: LocalPushManagerDelegate? + private let notificationCommunicationDecorator: NotificationCommunicationDecorator public static let stateDidChange: Notification.Name = .init(rawValue: "LocalPushManagerStateDidChange") @@ -82,8 +83,13 @@ public class LocalPushManager { private var tokens = [HACancellable]() - public init(server: Server) { + public init( + server: Server, + notificationCommunicationDecorator: NotificationCommunicationDecorator = + NotificationCommunicationDecoratorImpl() + ) { self.server = server + self.notificationCommunicationDecorator = notificationCommunicationDecorator updateSubscription() tokens.append(server.observe { [weak self] _ in @@ -181,6 +187,12 @@ public class LocalPushManager { }.recover { error in Current.Log.error("failed to get content, giving default: \(error)") return .value(baseContent) + }.then { content -> Guarantee in + if let sender = NotificationSenderParser.parse(from: content) { + return self.notificationCommunicationDecorator.decorate(content: content, sender: sender, api: api) + } else { + return .value(content) + } }.then { [add] content -> Promise in add(UNNotificationRequest(identifier: event.identifier, content: content, trigger: nil)) }.then { [subscription] () -> Promise in diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift new file mode 100644 index 0000000000..582afad751 --- /dev/null +++ b/Sources/Shared/Notifications/NotificationSender/NotificationCommunicationDecorator.swift @@ -0,0 +1,190 @@ +import Foundation +import ImageIO +import Intents +import PromiseKit +import UIKit +import UserNotifications + +public protocol NotificationCommunicationDecorator { + func decorate( + content: UNNotificationContent, + sender: NotificationSenderInfo, + api: HomeAssistantAPI? + ) -> Guarantee +} + +public final class NotificationCommunicationDecoratorImpl: NotificationCommunicationDecorator { + private let cache: NotificationIconCache + + public convenience init() { + self.init(cache: NotificationIconCacheImpl()) + } + + init(cache: NotificationIconCache) { + self.cache = cache + } + + public func decorate( + content: UNNotificationContent, + sender: NotificationSenderInfo, + api: HomeAssistantAPI? + ) -> Guarantee { + let title = content.title + guard !title.isEmpty else { return .value(content) } + + return buildIntent(sender: sender, title: title, body: content.body, api: api) + .map { intent in + do { + return try content.updating(from: intent) + } catch { + Current.Log.error("Communication notification updating(from:) failed: \(error)") + return content + } + } + } + + /// Internal so tests can drive it directly. Returns `Guarantee` because failures + /// always fall back to a best-effort intent rather than rejecting the pipeline. + func buildIntent( + sender: NotificationSenderInfo, + title: String, + body: String, + api: HomeAssistantAPI? + ) -> Guarantee { + avatarImage(for: sender.source, api: api).then { [self] image -> Guarantee in + let conversationID = conversationIdentifier(for: sender) + let handle = INPersonHandle(value: conversationID, type: .unknown) + var nameComponents = PersonNameComponents() + nameComponents.nickname = title + let person = INPerson( + personHandle: handle, + nameComponents: nameComponents, + displayName: title, + image: image, + contactIdentifier: nil, + customIdentifier: conversationID + ) + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: body, + speakableGroupName: nil, + conversationIdentifier: conversationID, + serviceName: "HomeAssistant", + sender: person, + attachments: nil + ) + return Guarantee { seal in + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + interaction.donate { error in + if let error { Current.Log.error("INInteraction donate failed: \(error)") } + seal(intent) + } + } + } + } + + /// MDI path is synchronous (no network). URL path downloads, caches, and downsamples the avatar. + private func avatarImage( + for source: NotificationSenderInfo.Source, + api: HomeAssistantAPI? + ) -> Guarantee { + switch source { + case let .mdi(name, background, foreground, _, _): + #if os(iOS) + let image = INImage( + icon: MaterialDesignIcons(serversideValueNamed: name, fallback: .bellIcon), + foreground: foreground, + background: background + ) + return .value(image) + #else + return .value(nil) + #endif + case let .iconURL(url, needsAuth): + let serverID = api?.server.identifier.rawValue + let cacheKey = notificationIconCacheKey(for: url, serverID: serverID) + if let cached = cache.data(forKey: cacheKey) { + return .value(INImage(imageData: cached)) + } + guard let api else { + Current.Log.error("Cannot download notification avatar without HomeAssistantAPI context") + return .value(nil) + } + return Guarantee { seal in + api.DownloadDataAt(url: url, needsAuth: needsAuth).done { [cache] downloadedFile in + defer { + try? FileManager.default.removeItem(at: downloadedFile) + } + guard let size = Self.fileSize(at: downloadedFile), size <= 5 * 1024 * 1024 else { + Current.Log.error("Downloaded avatar file is too large or size unknown: \(downloadedFile.path)") + seal(nil); return + } + guard let downsampled = Self.downsample(url: downloadedFile, maxDimension: 256) else { + Current.Log.error("Failed to decode/downsample downloaded avatar from \(downloadedFile.path)") + seal(nil); return + } + cache.setData(downsampled, forKey: cacheKey) + seal(INImage(imageData: downsampled)) + }.catch { error in + Current.Log.error("Failed to download notification avatar from \(url): \(error)") + seal(nil) + } + } + } + } + + /// Returns a stable, human-readable conversation identifier so iOS groups successive + /// notifications from the same automation. Kept as a raw string (no hashing) so it can + /// be eyeballed in logs and Siri suggestion dumps when diagnosing grouping issues. + /// The `|` separator cannot appear in MDI names or 6-digit hex strings, so collisions + /// across distinct inputs are not possible. + private func conversationIdentifier(for sender: NotificationSenderInfo) -> String { + let iconKey: String + switch sender.source { + case let .mdi(name, _, _, colorString, iconColorString): + iconKey = "\(name)|\(colorString ?? "default")|\(iconColorString ?? "white")" + case let .iconURL(url, _): + iconKey = url.absoluteString + } + return "ha-sender:\(sender.senderName.lowercased()):\(iconKey)" + } + + private static func fileSize(at url: URL) -> Int64? { + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + return values?.fileSize.map(Int64.init) + } + + /// Reduce the source image to at most `maxDimension` px on the longer side, returning + /// fresh PNG bytes suitable for `INImage(imageData:)`. Returns `nil` if the image + /// isn't a decodable format. + /// + /// ImageIO option choice (these operate at different levels): + /// - `kCGImageSourceShouldCache: false` on the SOURCE: ImageIO must not cache the full + /// decoded source pixels in its tile store. Critical for staying under the NSE's + /// ~24 MB ceiling when the source happens to be a large JPEG/PNG. + /// - `kCGImageSourceShouldCacheImmediately: true` on the THUMBNAIL: decode the small + /// thumbnail eagerly rather than lazily, so the bitmap is realised here under our + /// memory budget rather than later inside the Intents framework. + /// + /// `INImage` has no `init(cgImage:)`, so we round-trip back through PNG bytes. + private static func downsample(url: URL, maxDimension: CGFloat) -> Data? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { return nil } + let downsampleOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxDimension, + ] as CFDictionary + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { + return nil + } + #if os(iOS) + return UIImage(cgImage: thumbnail).pngData() + #else + return nil + #endif + } +} diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift b/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift new file mode 100644 index 0000000000..af5c5c2eb5 --- /dev/null +++ b/Sources/Shared/Notifications/NotificationSender/NotificationIconCache.swift @@ -0,0 +1,113 @@ +import CryptoKit +import Foundation + +/// Disk-backed byte cache. +/// +/// Keys are opaque to the protocol; use `notificationIconCacheKey(for:)` to derive +/// the canonical key for a URL so different callers reach the same entry. +public protocol NotificationIconCache { + func data(forKey key: String) -> Data? + func setData(_ data: Data, forKey key: String) +} + +public func notificationIconCacheKey(for url: URL, serverID: String? = nil) -> String { + let stringToHash: String + if let serverID { + stringToHash = "\(serverID)|\(url.absoluteString)" + } else { + stringToHash = url.absoluteString + } + let digest = SHA256.hash(data: Data(stringToHash.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + ".img" +} + +public final class NotificationIconCacheImpl: NotificationIconCache { + private let directory: URL + private let maxEntries: Int + private let queue = DispatchQueue(label: "io.home-assistant.NotificationIconCache") + /// Cross-process coordinator: the App Group container is shared between the host app, + /// the Notification Service Extension, and Watch extensions. The serial queue alone + /// only guarantees ordering within this process. + private let coordinator = NSFileCoordinator() + + public convenience init() { + let dir = AppConstants.AppGroupContainer + .appendingPathComponent("notification-icons", isDirectory: true) + self.init(directory: dir, maxEntries: 50) + } + + init(directory: URL, maxEntries: Int) { + self.directory = directory + self.maxEntries = maxEntries + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + Current.Log.error("NotificationIconCache: failed to create directory \(directory.path): \(error)") + } + } + + public func data(forKey key: String) -> Data? { + queue.sync { + let url = directory.appendingPathComponent(key) + var read: Data? + var coordinatorError: NSError? + coordinator.coordinate(readingItemAt: url, error: &coordinatorError) { coordinatedURL in + read = try? Data(contentsOf: coordinatedURL) + } + guard let data = read else { return nil } + // Best-effort mtime touch so LRU tracks reads as well as writes. + // Failure here only means this entry may be evicted slightly earlier — not a + // correctness concern, so no log. + try? FileManager.default.setAttributes( + [.modificationDate: Date()], + ofItemAtPath: url.path + ) + return data + } + } + + public func setData(_ data: Data, forKey key: String) { + queue.sync { + let url = directory.appendingPathComponent(key) + var writeError: Error? + var coordinatorError: NSError? + coordinator + .coordinate(writingItemAt: url, options: .forReplacing, error: &coordinatorError) { coordinatedURL in + do { + try data.write(to: coordinatedURL, options: .atomic) + } catch { + writeError = error + } + } + if let error = (writeError ?? coordinatorError) { + Current.Log.error("NotificationIconCache: write failed for key \(key): \(error)") + } + prune() + } + } + + /// Drop the oldest files until count <= maxEntries. Must be called on `queue`. + private func prune() { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { return } + guard entries.count > maxEntries else { return } + + // Snapshot (url, mtime) once per entry, then sort — avoids re-stat'ing inside + // the comparator and keeps the prefetched cache from `contentsOfDirectory` the + // sole source of mtimes. + let dated = entries.map { url -> (URL, Date) in + let date = ( + try? url.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate + ) ?? .distantPast + return (url, date) + } + let sorted = dated.sorted { $0.1 < $1.1 }.map(\.0) + for url in sorted.prefix(entries.count - maxEntries) { + try? FileManager.default.removeItem(at: url) + } + } +} diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift new file mode 100644 index 0000000000..4fe82d78c6 --- /dev/null +++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderInfo.swift @@ -0,0 +1,53 @@ +import Foundation + +// UIKit is available on watchOS (limited subset). UIColor is used in Source.mdi; +// MDI rendering itself is guarded by #if os(iOS) in NotificationCommunicationDecorator. +import UIKit + +/// Parsed representation of the icon/sender fields in a push payload that should +/// trigger Communication Notification styling. +public struct NotificationSenderInfo: Equatable { + public enum Source: Equatable { + /// User-supplied image URL. `needsAuth` is true when the URL string begins with `/` + /// (matching the rule in `NotificationAttachmentParserURL`). + case iconURL(URL, needsAuth: Bool) + + /// Built-in Material Design Icon, rendered onto a colored square. + /// `background` defaults to `AppConstants.tintColor` when `color` is absent. + /// `foreground` defaults to `.white` when `notification_icon_color` is absent. + case mdi( + name: String, + background: UIColor, + foreground: UIColor, + colorString: String?, + iconColorString: String? + ) + + public static func == (lhs: Source, rhs: Source) -> Bool { + switch (lhs, rhs) { + case let (.iconURL(lhsURL, lhsNeedsAuth), .iconURL(rhsURL, rhsNeedsAuth)): + return lhsURL == rhsURL && lhsNeedsAuth == rhsNeedsAuth + case let ( + .mdi(lhsName, lhsBg, lhsFg, lhsColStr, lhsIconColStr), + .mdi(rhsName, rhsBg, rhsFg, rhsColStr, rhsIconColStr) + ): + if lhsName != rhsName { return false } + let bgEqual = (lhsColStr != nil && rhsColStr != nil) ? (lhsColStr == rhsColStr) : lhsBg.isEqual(rhsBg) + let fgEqual = (lhsIconColStr != nil && rhsIconColStr != nil) ? (lhsIconColStr == rhsIconColStr) : lhsFg + .isEqual(rhsFg) + return bgEqual && fgEqual + default: + return false + } + } + } + + public let source: Source + /// The notification's title — used as the sender's display name. Required, non-empty. + public let senderName: String + + public init(source: Source, senderName: String) { + self.source = source + self.senderName = senderName + } +} diff --git a/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift b/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift new file mode 100644 index 0000000000..3845ba1b7c --- /dev/null +++ b/Sources/Shared/Notifications/NotificationSender/NotificationSenderParser.swift @@ -0,0 +1,56 @@ +import Foundation +import UIColor_Hex_Swift +import UIKit +import UserNotifications + +public enum NotificationSenderParser { + public static func parse(from content: UNNotificationContent) -> NotificationSenderInfo? { + let senderName = content.title + guard !senderName.isEmpty else { return nil } + + let userInfo = content.userInfo + let nestedData = userInfo["data"] as? [String: Any] + + func value(forKey key: String) -> Any? { + userInfo[key] ?? nestedData?[key] + } + + // icon_url wins when both are present. + if let urlString = value(forKey: "icon_url") as? String, + !urlString.isEmpty, + let url = URL(string: urlString) { + return NotificationSenderInfo( + source: .iconURL(url, needsAuth: urlString.hasPrefix("/")), + senderName: senderName + ) + } + + if let mdiName = value(forKey: "notification_icon") as? String, !mdiName.isEmpty { + let colorString = value(forKey: "color") as? String + let iconColorString = value(forKey: "notification_icon_color") as? String + let background = colorString.flatMap(Self.color(fromHex:)) + ?? AppConstants.tintColor + let foreground = iconColorString.flatMap(Self.color(fromHex:)) + ?? .white + return NotificationSenderInfo( + source: .mdi( + name: mdiName, + background: background, + foreground: foreground, + colorString: colorString, + iconColorString: iconColorString + ), + senderName: senderName + ) + } + + return nil + } + + /// Returns nil for malformed inputs so the caller can fall back to a default, + /// instead of relying on UIColor(hex:)'s crash-on-bad-input behavior. + private static func color(fromHex hex: String) -> UIColor? { + guard let color = try? UIColor(rgba_throws: hex) else { return nil } + return color + } +} diff --git a/Sources/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/SharedPush/Sources/NotificationParserLegacy.swift index 58be8ff010..10fccf7f21 100644 --- a/Sources/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/SharedPush/Sources/NotificationParserLegacy.swift @@ -160,6 +160,16 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { addAttachment(key: "image", contentType: "jpeg") addAttachment(key: "audio", contentType: "waveformaudio") + for key in NotificationDecorationPayloadKey.notificationDecorationKeys { + if let value = data[key.rawValue] { + payload[key.rawValue] = value + } + } + if payload[NotificationDecorationPayloadKey.iconURL.rawValue] != nil || + payload[NotificationDecorationPayloadKey.notificationIcon.rawValue] != nil { + needsMutableContent = true + } + payload["url"] = data["url"] payload["shortcut"] = data["shortcut"] payload["presentation_options"] = data["presentation_options"] @@ -221,7 +231,9 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { homeassistant["live_update"] = true for key in [ "tag", "critical_text", "progress", "progress_max", "chronometer", - "when", "when_relative", "notification_icon", "notification_icon_color", + "when", "when_relative", + NotificationDecorationPayloadKey.notificationIcon.rawValue, + NotificationDecorationPayloadKey.notificationIconColor.rawValue, ] { if let value = data[key] { homeassistant[key] = value @@ -281,6 +293,20 @@ enum LegacyNotificationCommandType: String { case updateWidgets = "update_widgets" } +enum NotificationDecorationPayloadKey: String, CaseIterable { + case iconURL = "icon_url" + case notificationIcon = "notification_icon" + case notificationIconColor = "notification_icon_color" + case color + + static let notificationDecorationKeys: [Self] = [ + .iconURL, + .notificationIcon, + .notificationIconColor, + .color, + ] +} + private extension Dictionary where Value == Any { mutating func mutate( _ key: Key, diff --git a/Tests/Shared/LocalPushEvent.test.swift b/Tests/Shared/LocalPushEvent.test.swift index 2239b36766..fa6d4bd5c0 100644 --- a/Tests/Shared/LocalPushEvent.test.swift +++ b/Tests/Shared/LocalPushEvent.test.swift @@ -57,6 +57,26 @@ class LocalPushEventTests: XCTestCase { XCTAssertEqual(content.interruptionLevel, .active) } + func testNotificationIconFromDataIsPreservedForDecoration() throws { + let data = HAData.dictionary([ + "message": "some_message", + "title": "Phone", + "data": [ + "notification_icon": "mdi:cellphone", + "notification_icon_color": "#FFFFFF", + "color": "#03A9F4", + ], + ]) + + let content = try LocalPushEvent(data: data).content(server: server) + + XCTAssertEqual(content.title, "Phone") + XCTAssertEqual(content.userInfo["notification_icon"] as? String, "mdi:cellphone") + XCTAssertEqual(content.userInfo["notification_icon_color"] as? String, "#FFFFFF") + XCTAssertEqual(content.userInfo["color"] as? String, "#03A9F4") + XCTAssertNotNil(NotificationSenderParser.parse(from: content)) + } + func testFullWithoutSound() { let event = LocalPushEvent( headers: [:], diff --git a/Tests/Shared/LocalPushManager.test.swift b/Tests/Shared/LocalPushManager.test.swift index d63dc91d69..f6a25a0b59 100644 --- a/Tests/Shared/LocalPushManager.test.swift +++ b/Tests/Shared/LocalPushManager.test.swift @@ -47,13 +47,21 @@ class LocalPushManagerTests: XCTestCase { } } - private func setUpManager(webhookID: String, version: Version? = nil) { + private func setUpManager( + webhookID: String, + version: Version? = nil, + notificationCommunicationDecorator: NotificationCommunicationDecorator = + NotificationCommunicationDecoratorImpl() + ) { api.server.info.connection.webhookID = webhookID if let version { api.server.info.version = version } - manager = LocalPushManager(server: api.server) + manager = LocalPushManager( + server: api.server, + notificationCommunicationDecorator: notificationCommunicationDecorator + ) manager.add = { [weak self] request in let (promise, resolver) = Promise.pending() self?.added.append((request, resolver)) @@ -381,6 +389,62 @@ class LocalPushManagerTests: XCTestCase { .contains(where: { $0.request.type == "mobile_app/push_notification_confirm" }) ) } + + func testEventCommunicationDecoratorInvoked() throws { + class SpyDecorator: NotificationCommunicationDecorator { + var decorateCalled = false + var apiUsed: HomeAssistantAPI? + + func decorate( + content: UNNotificationContent, + sender: NotificationSenderInfo, + api: HomeAssistantAPI? + ) -> Guarantee { + decorateCalled = true + apiUsed = api + return .value(content) + } + } + + let spy = SpyDecorator() + + setUpManager(webhookID: "webhook1", notificationCommunicationDecorator: spy) + + let expectation1 = expectation(description: "contentRequestsChanged") + attachmentManager.contentRequestsChanged = { + expectation1.fulfill() + } + + let sub = try XCTUnwrap(apiConnection.pendingSubscriptions.first) + sub.handler(sub.cancellable, .dictionary([ + "message": "test_message", + "notification_icon": "mdi:dishwasher", + "data": [ + "tag": "test_tag", + ], + ])) + + waitForExpectations(timeout: 10.0) + + let req = try XCTUnwrap(attachmentManager.contentRequests.first) + req.1(with(UNMutableNotificationContent()) { + $0.body = "test_message_modified" + $0.title = "test_title" + $0.userInfo = [ + "notification_icon": "mdi:dishwasher", + ] + }) + + let expectation2 = expectation(description: "addedChanged") + addedChanged = { + expectation2.fulfill() + } + + waitForExpectations(timeout: 10.0) + + XCTAssertTrue(spy.decorateCalled) + XCTAssertIdentical(spy.apiUsed, api) + } } private class FakeNotificationAttachmentManager: NotificationAttachmentManager { diff --git a/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift new file mode 100644 index 0000000000..3578e1fda4 --- /dev/null +++ b/Tests/Shared/NotificationSender/NotificationCommunicationDecoratorTests.swift @@ -0,0 +1,194 @@ +import Intents +import OHHTTPStubs +import PromiseKit +@testable import Shared +import UIKit +import UserNotifications +import XCTest + +final class NotificationCommunicationDecoratorTests: XCTestCase { + private var cache: InMemoryIconCache! + private var decorator: NotificationCommunicationDecoratorImpl! + private var api: FakeHomeAssistantAPI! + + override func setUp() { + super.setUp() + cache = InMemoryIconCache() + decorator = NotificationCommunicationDecoratorImpl(cache: cache) + api = FakeHomeAssistantAPI(server: .fake()) + } + + override func tearDown() { + HTTPStubs.removeAllStubs() + super.tearDown() + } + + private func content(title: String = "Dishwasher", body: String = "Cycle complete.") -> UNNotificationContent { + let c = UNMutableNotificationContent() + c.title = title + c.body = body + return c + } + + // MARK: - MDI + + func testBuildIntent_mdi_setsSenderNameAndImage() throws { + let info = NotificationSenderInfo( + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: "#FF0000", + iconColorString: "#FFFFFF" + ), + senderName: "Front Door" + ) + let intent = try hang(Promise(decorator.buildIntent( + sender: info, + title: "Front Door", + body: "Opened", + api: api + ))) + + XCTAssertEqual(intent.sender?.displayName, "Front Door") + XCTAssertNotNil(intent.sender?.image, "MDI source must produce a non-nil INImage") + XCTAssertEqual(intent.content, "Opened") + XCTAssertEqual(intent.serviceName, "HomeAssistant") + } + + func testBuildIntent_conversationIdentifier_stableAcrossCalls() throws { + let info = NotificationSenderInfo( + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: "#FF0000", + iconColorString: "#FFFFFF" + ), + senderName: "Front Door" + ) + let intent1 = try hang(Promise(decorator.buildIntent(sender: info, title: "Front Door", body: "x", api: api))) + let intent2 = try hang(Promise(decorator.buildIntent(sender: info, title: "Front Door", body: "y", api: api))) + XCTAssertEqual(intent1.conversationIdentifier, intent2.conversationIdentifier) + XCTAssertFalse(intent1.conversationIdentifier?.isEmpty ?? true) + } + + func testBuildIntent_conversationIdentifier_differsForDifferentSenderNames() throws { + let a = NotificationSenderInfo( + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: "#FF0000", + iconColorString: "#FFFFFF" + ), + senderName: "Front Door" + ) + let b = NotificationSenderInfo( + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: "#FF0000", + iconColorString: "#FFFFFF" + ), + senderName: "Back Door" + ) + let ia = try hang(Promise(decorator.buildIntent(sender: a, title: "Front Door", body: "x", api: api))) + let ib = try hang(Promise(decorator.buildIntent(sender: b, title: "Back Door", body: "x", api: api))) + XCTAssertNotEqual(ia.conversationIdentifier, ib.conversationIdentifier) + } + + func testDecorate_emptyTitle_returnsOriginalContentUnchanged() throws { + let original = content(title: "", body: "x") + let info = NotificationSenderInfo( + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: "#FF0000", + iconColorString: "#FFFFFF" + ), + senderName: "X" // ignored — decorator uses content.title + ) + let result = try hang(Promise(decorator.decorate(content: original, sender: info, api: api))) + XCTAssertEqual(result, original) + } + + // MARK: - URL + + func testBuildIntent_iconURL_cacheHit_skipsDownload() throws { + let url = try XCTUnwrap(URL(string: "https://example.com/avatar.png")) + let pngBytes = makeRedPNG() // helper below + cache.setData(pngBytes, forKey: notificationIconCacheKey(for: url, serverID: api.server.identifier.rawValue)) + + let info = NotificationSenderInfo( + source: .iconURL(url, needsAuth: false), + senderName: "Alex" + ) + let intent = try hang(Promise(decorator.buildIntent( + sender: info, title: "Alex", body: "Hi", api: api + ))) + XCTAssertNotNil(intent.sender?.image) + // Cache hit means no HTTP call should have occurred. We assert that by + // confirming no stubs are required for this test to pass. + } + + func testBuildIntent_iconURL_cacheMiss_downloadsThenCaches() throws { + let url = try XCTUnwrap(URL(string: "https://homeassistant.local:8123/icon.png")) + let pngBytes = makeRedPNG() + + let stubDesc = HTTPStubs.stubRequests(passingTest: { $0.url == url }) { _ in + HTTPStubsResponse(data: pngBytes, statusCode: 200, headers: nil) + } + defer { HTTPStubs.removeStub(stubDesc) } + + let info = NotificationSenderInfo( + source: .iconURL(url, needsAuth: false), + senderName: "Alex" + ) + let intent = try hang(Promise(decorator.buildIntent( + sender: info, title: "Alex", body: "Hi", api: api + ))) + XCTAssertNotNil(intent.sender?.image) + XCTAssertNotNil( + cache.data(forKey: notificationIconCacheKey(for: url, serverID: api.server.identifier.rawValue)), + "after download, the image must be cached" + ) + } + + func testBuildIntent_iconURL_downloadFails_returnsIntentWithNilImage() throws { + let url = try XCTUnwrap(URL(string: "https://homeassistant.local:8123/missing.png")) + let stubDesc = HTTPStubs.stubRequests(passingTest: { $0.url == url }) { _ in + HTTPStubsResponse(data: Data(), statusCode: 500, headers: nil) + } + defer { HTTPStubs.removeStub(stubDesc) } + + let info = NotificationSenderInfo( + source: .iconURL(url, needsAuth: false), + senderName: "Alex" + ) + let intent = try hang(Promise(decorator.buildIntent( + sender: info, title: "Alex", body: "Hi", api: api + ))) + XCTAssertNil(intent.sender?.image, "failed download must produce a nil image, not crash") + XCTAssertEqual(intent.sender?.displayName, "Alex", "we still build a sender so styling proceeds") + } + + private func makeRedPNG() -> Data { + UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32)).pngData { _ in + UIColor.red.setFill() + UIRectFill(CGRect(x: 0, y: 0, width: 32, height: 32)) + } + } +} + +// MARK: - Test doubles + +private final class InMemoryIconCache: NotificationIconCache { + var store: [String: Data] = [:] + func data(forKey key: String) -> Data? { store[key] } + func setData(_ data: Data, forKey key: String) { store[key] = data } +} + +private final class FakeHomeAssistantAPI: HomeAssistantAPI {} diff --git a/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift b/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift new file mode 100644 index 0000000000..c4135029e8 --- /dev/null +++ b/Tests/Shared/NotificationSender/NotificationIconCacheTests.swift @@ -0,0 +1,78 @@ +@testable import Shared +import XCTest + +final class NotificationIconCacheTests: XCTestCase { + private var cache: NotificationIconCacheImpl! + private var tempDir: URL! + + override func setUp() { + super.setUp() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("NotificationIconCacheTests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + cache = NotificationIconCacheImpl(directory: tempDir, maxEntries: 3) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + super.tearDown() + } + + func testMissReturnsNil() { + XCTAssertNil(cache.data(forKey: "missing")) + } + + func testWriteThenRead_roundTrips() { + let payload = Data([0x89, 0x50, 0x4E, 0x47]) // PNG magic + cache.setData(payload, forKey: "abc") + XCTAssertEqual(cache.data(forKey: "abc"), payload) + } + + func testEviction_dropsOldestWhenOverLimit() throws { + cache.setData(Data([1]), forKey: "k1") + cache.setData(Data([2]), forKey: "k2") + cache.setData(Data([3]), forKey: "k3") + + let fm = FileManager.default + let now = Date() + try fm.setAttributes( + [.modificationDate: now.addingTimeInterval(-30)], + ofItemAtPath: tempDir.appendingPathComponent("k1").path + ) + try fm.setAttributes( + [.modificationDate: now.addingTimeInterval(-20)], + ofItemAtPath: tempDir.appendingPathComponent("k2").path + ) + try fm.setAttributes( + [.modificationDate: now.addingTimeInterval(-10)], + ofItemAtPath: tempDir.appendingPathComponent("k3").path + ) + + cache.setData(Data([4]), forKey: "k4") // triggers eviction; max is 3 + + XCTAssertNil(cache.data(forKey: "k1"), "k1 should be evicted") + XCTAssertEqual(cache.data(forKey: "k4"), Data([4])) + } + + func testKeyHashing_returnsSameKeyForSameURL() throws { + let key1 = try notificationIconCacheKey(for: XCTUnwrap(URL(string: "https://example.com/a.png"))) + let key2 = try notificationIconCacheKey(for: XCTUnwrap(URL(string: "https://example.com/a.png"))) + XCTAssertEqual(key1, key2) + } + + func testKeyHashing_returnsDifferentKeysForDifferentURLs() throws { + let k1 = try notificationIconCacheKey(for: XCTUnwrap(URL(string: "https://example.com/a.png"))) + let k2 = try notificationIconCacheKey(for: XCTUnwrap(URL(string: "https://example.com/b.png"))) + XCTAssertNotEqual(k1, k2) + } + + func testKeyHashing_withServerID() throws { + let url = try XCTUnwrap(URL(string: "https://example.com/a.png")) + let keyWithoutServer = notificationIconCacheKey(for: url) + let keyWithServer1 = notificationIconCacheKey(for: url, serverID: "server1") + let keyWithServer2 = notificationIconCacheKey(for: url, serverID: "server2") + + XCTAssertNotEqual(keyWithoutServer, keyWithServer1) + XCTAssertNotEqual(keyWithServer1, keyWithServer2) + } +} diff --git a/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift b/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift new file mode 100644 index 0000000000..889ff51ac0 --- /dev/null +++ b/Tests/Shared/NotificationSender/NotificationSenderInfoTests.swift @@ -0,0 +1,60 @@ +@testable import Shared +import UIKit +import XCTest + +final class NotificationSenderInfoTests: XCTestCase { + func testEquatable_sameValues_areEqual() { + let a = NotificationSenderInfo( + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: nil, + iconColorString: nil + ), + senderName: "Front Door" + ) + let b = NotificationSenderInfo( + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: nil, + iconColorString: nil + ), + senderName: "Front Door" + ) + XCTAssertEqual(a, b) + } + + func testEquatable_differentSenderName_areNotEqual() { + let a = NotificationSenderInfo( + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: nil, + iconColorString: nil + ), + senderName: "Front Door" + ) + let b = NotificationSenderInfo( + source: .mdi( + name: "mdi:door", + background: .red, + foreground: .white, + colorString: nil, + iconColorString: nil + ), + senderName: "Back Door" + ) + XCTAssertNotEqual(a, b) + } + + func testEquatable_iconURLNeedsAuthDiffers_areNotEqual() throws { + let url = try XCTUnwrap(URL(string: "/local/x.png")) + let a = NotificationSenderInfo(source: .iconURL(url, needsAuth: true), senderName: "X") + let b = NotificationSenderInfo(source: .iconURL(url, needsAuth: false), senderName: "X") + XCTAssertNotEqual(a, b) + } +} diff --git a/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift b/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift new file mode 100644 index 0000000000..782682d3b4 --- /dev/null +++ b/Tests/Shared/NotificationSender/NotificationSenderParserTests.swift @@ -0,0 +1,135 @@ +@testable import Shared +import UIKit +import UserNotifications +import XCTest + +final class NotificationSenderParserTests: XCTestCase { + private func content(title: String = "Hi", userInfo: [AnyHashable: Any]) -> UNNotificationContent { + let c = UNMutableNotificationContent() + c.title = title + c.userInfo = userInfo + return c + } + + /// Asserts that a UIColor resolves to the same values as `AppConstants.tintColor` + /// in both light and dark trait collections. + /// + /// Direct `XCTAssertEqual(color, AppConstants.tintColor)` is unreliable: `tintColor` + /// is a dynamic `UIColor` built from a closure, and UIColor equality on two + /// independently-constructed dynamic providers compares closure identity, not + /// resolved component values. We resolve both sides in each trait collection + /// and compare those concrete values, which keeps the assertion in sync with + /// whatever `tintColor`'s provider currently returns. + private func assertIsTintColor(_ color: UIColor, file: StaticString = #file, line: UInt = #line) { + let expected = AppConstants.tintColor + for style in [UIUserInterfaceStyle.light, .dark] { + let traits = UITraitCollection(userInterfaceStyle: style) + XCTAssertEqual( + color.resolvedColor(with: traits), + expected.resolvedColor(with: traits), + "Expected tint-color-equivalent in style \(style), but got \(color)", + file: file, + line: line + ) + } + } + + func testNoIconFields_returnsNil() { + XCTAssertNil(NotificationSenderParser.parse(from: content(userInfo: [:]))) + } + + func testEmptyTitle_returnsNil_evenWithIcon() { + let c = content(title: "", userInfo: ["notification_icon": "mdi:door"]) + XCTAssertNil(NotificationSenderParser.parse(from: c)) + } + + func testMdiOnly_defaults() { + let parsed = NotificationSenderParser.parse(from: content(userInfo: [ + "notification_icon": "mdi:door", + ])) + guard case let .mdi(name, background, foreground, _, _) = parsed?.source else { + return XCTFail("expected mdi source, got \(String(describing: parsed))") + } + XCTAssertEqual(name, "mdi:door") + assertIsTintColor(background) + XCTAssertEqual(foreground, UIColor.white) + XCTAssertEqual(parsed?.senderName, "Hi") + } + + func testMdiInNestedData_defaults() { + let parsed = NotificationSenderParser.parse(from: content(userInfo: [ + "data": [ + "notification_icon": "mdi:cellphone", + ], + ])) + guard case let .mdi(name, background, foreground, _, _) = parsed?.source else { + return XCTFail("expected mdi source, got \(String(describing: parsed))") + } + XCTAssertEqual(name, "mdi:cellphone") + assertIsTintColor(background) + XCTAssertEqual(foreground, UIColor.white) + XCTAssertEqual(parsed?.senderName, "Hi") + } + + func testMdiWithColor_appliedAsBackground() { + let parsed = NotificationSenderParser.parse(from: content(userInfo: [ + "notification_icon": "mdi:door", + "color": "#2196F3", + ])) + guard case let .mdi(_, background, _, _, _) = parsed?.source else { return XCTFail() } + XCTAssertEqual(background, UIColor(hex: "#2196F3")) + } + + func testMdiWithNotificationIconColor_appliedAsForeground() { + let parsed = NotificationSenderParser.parse(from: content(userInfo: [ + "notification_icon": "mdi:door", + "notification_icon_color": "#FF5722", + ])) + guard case let .mdi(_, _, foreground, _, _) = parsed?.source else { return XCTFail() } + XCTAssertEqual(foreground, UIColor(hex: "#FF5722")) + } + + func testMdiWithMalformedColor_fallsBackToDefault() { + let parsed = NotificationSenderParser.parse(from: content(userInfo: [ + "notification_icon": "mdi:door", + "color": "not-a-color", + ])) + guard case let .mdi(_, background, _, _, _) = parsed?.source else { return XCTFail() } + assertIsTintColor(background) + } + + func testIconURLAbsolute_noAuth() throws { + let parsed = NotificationSenderParser.parse(from: content(userInfo: [ + "icon_url": "https://example.com/x.png", + ])) + guard case let .iconURL(url, needsAuth) = parsed?.source else { return XCTFail() } + XCTAssertEqual(url, try XCTUnwrap(URL(string: "https://example.com/x.png"))) + XCTAssertFalse(needsAuth) + } + + func testIconURLRelative_needsAuth() throws { + let parsed = NotificationSenderParser.parse(from: content(userInfo: [ + "icon_url": "/local/x.png", + ])) + guard case let .iconURL(url, needsAuth) = parsed?.source else { return XCTFail() } + XCTAssertEqual(url, try XCTUnwrap(URL(string: "/local/x.png"))) + XCTAssertTrue(needsAuth) + } + + func testIconURLInvalidString_returnsNil() { + let parsed = NotificationSenderParser.parse(from: content(userInfo: [ + "icon_url": "", + ])) + XCTAssertNil(parsed) + } + + func testIconURLWinsOverNotificationIcon() { + let parsed = NotificationSenderParser.parse(from: content(userInfo: [ + "notification_icon": "mdi:door", + "icon_url": "https://example.com/x.png", + ])) + guard case .iconURL = parsed?.source else { + return XCTFail("icon_url must take precedence") + } + } +}