diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index f8afef8822..d0e0bc461d 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1237,17 +1237,27 @@ 5D4737422F241342009A70EA /* FolderDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D4737412F241342009A70EA /* FolderDetailView.swift */; }; 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 */; }; 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 */; }; 84F7755EFB03C3F463292ABF /* Pods-watchOS-Shared-watchOS-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6B55CB9064A0477C9F456B6A /* Pods-watchOS-Shared-watchOS-metadata.plist */; }; 87ADC61008345747CABC2270 /* KioskSettingsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14444A34DA125693568C7035 /* KioskSettingsTable.swift */; }; + 897D7538631C46D4BD849CF5 /* NotificationsCommandManagerLiveActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */; }; 8E5FA96C740F1D671966CEA9 /* Pods-iOS-Extensions-NotificationContent-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = B613440AEDD4209862503F5D /* Pods-iOS-Extensions-NotificationContent-metadata.plist */; }; 999549244371450BC98C700E /* Pods_iOS_Extensions_PushProvider.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 608CFDA223EBCDF01B946093 /* Pods_iOS_Extensions_PushProvider.framework */; }; - 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 */; }; A5A3C1932BE1F4A40EA78754 /* Pods-iOS-Extensions-Matter-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 392B0C44197C98E2653932A5 /* Pods-iOS-Extensions-Matter-metadata.plist */; }; + A95FDD0C2F6B89C6008EF72F /* LiveActivityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD0A2F6B89C6008EF72F /* LiveActivityRegistry.swift */; }; + A95FDD0D2F6B89C6008EF72F /* HALiveActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD092F6B89C6008EF72F /* HALiveActivityAttributes.swift */; }; + A95FDD0F2F6B8A19008EF72F /* HandlerLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD0E2F6B8A19008EF72F /* HandlerLiveActivity.swift */; }; + A95FDD142F6B8A3E008EF72F /* HADynamicIslandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD102F6B8A3E008EF72F /* HADynamicIslandView.swift */; }; + A95FDD152F6B8A3E008EF72F /* HALiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD112F6B8A3E008EF72F /* HALiveActivityConfiguration.swift */; }; + A95FDD162F6B8A3E008EF72F /* HALockScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD122F6B8A3E008EF72F /* HALockScreenView.swift */; }; + A95FDD192F6B8A5B008EF72F /* LiveActivitySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95FDD172F6B8A5B008EF72F /* LiveActivitySettingsView.swift */; }; AB3E076F146799C008ACB0EA /* Pods_iOS_Extensions_NotificationContent.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B7D8DAEFAD435091FDDD61E7 /* Pods_iOS_Extensions_NotificationContent.framework */; }; B6022213226DAC9D00E8DBFE /* ScaledFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6022212226DAC9D00E8DBFE /* ScaledFont.swift */; }; B60248001FBD343000998205 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = B60247FE1FBD343000998205 /* InfoPlist.strings */; }; @@ -1503,7 +1513,7 @@ B6E42613215C4333007FEB7E /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D03D891720E0A85200D4F28D /* Shared.framework */; }; BB77559927344584B2C0E987 /* OnboardingAuthError.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A7DD090A1D41ADB9374E7A /* OnboardingAuthError.test.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 */; }; C35621B95F7E4548BC8F6D75 /* FolderEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BECEB2525564358A124F818 /* FolderEditView.swift */; }; C574CE3276BCE901743FF8C9 /* KioskSettings.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFD4B475DDA9447E45A9BAD3 /* KioskSettings.test.swift */; }; @@ -1550,7 +1560,7 @@ D46379541BA5FD96D6E7D328 /* KioskSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 402432B9CC897C6278B08A79 /* KioskSettings.swift */; }; D8B4F2A61E9C73058AF2D49E /* KioskSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3E91F5B8D42A6E0F13B74 /* KioskSettingsViewModel.swift */; }; D9A6697AF4D05BB8DE822A54 /* Pods_iOS_Extensions_Share.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33CA7FF55788E7084DA5E4B3 /* Pods_iOS_Extensions_Share.framework */; }; - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */; }; + DA6F4C18D66EDBA5DCEAE833 /* DatabaseMigration.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.test.swift */; }; E92E09E3A93650D56E3C5093 /* KioskScreensaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62CDCFDB29D283A7902A3ABE /* KioskScreensaverViewController.swift */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; @@ -3039,8 +3049,9 @@ 50D9C22ED2834EC9DAAC63AC /* Pods-iOS-Extensions-Intents.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.debug.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.debug.xcconfig"; sourceTree = ""; }; 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 = ""; }; - 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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -3058,7 +3069,7 @@ 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 = ""; }; 825E1E44BA9ABF1BF53733D3 /* KioskConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskConstants.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 = ""; }; @@ -3070,14 +3081,22 @@ 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 = ""; }; 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 = ""; }; + A95FDD092F6B89C6008EF72F /* HALiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HALiveActivityAttributes.swift; sourceTree = ""; }; + A95FDD0A2F6B89C6008EF72F /* LiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistry.swift; sourceTree = ""; }; + A95FDD0E2F6B8A19008EF72F /* HandlerLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandlerLiveActivity.swift; sourceTree = ""; }; + A95FDD102F6B8A3E008EF72F /* HADynamicIslandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HADynamicIslandView.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; ADC769271BB34C474C2D1E24 /* Pods-iOS-Shared-iOS-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-metadata.plist"; sourceTree = ""; }; B26248E8DEAC8C13210A6587 /* Pods-iOS-Extensions-Widgets.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Widgets.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Widgets/Pods-iOS-Extensions-Widgets.beta.xcconfig"; sourceTree = ""; }; B2F5238669D8A7416FBD2B55 /* Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; path = "Pods/Pods-iOS-Shared-iOS-Tests-Shared-metadata.plist"; sourceTree = ""; }; + B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandlerLiveActivityTests.swift; sourceTree = ""; }; B6022212226DAC9D00E8DBFE /* ScaledFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScaledFont.swift; sourceTree = ""; }; B60247ED1FBD21C600998205 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; B60247FF1FBD343000998205 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -3392,7 +3411,7 @@ B6FD0573228411B200AC45BA /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; 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; }; - 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; }; 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 = ""; }; C00AE2FDC80CA2FFDFCA2B2B /* KioskModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KioskModeManager.swift; sourceTree = ""; }; @@ -3435,6 +3454,7 @@ D0FF79CB20D778B50034574D /* ClientEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEvent.swift; sourceTree = ""; }; D0FF79CD20D85C3A0034574D /* ClientEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEventStore.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -3888,6 +3908,7 @@ 1171506E24DFCDE60065E874 /* Widgets */ = { isa = PBXGroup; children = ( + A95FDD132F6B8A3E008EF72F /* LiveActivity */, 428863582EF9641400319CF4 /* CameraList */, 95A32294D4A340198B769AAB /* CommonlyUsedEntities */, 42FF5E8F2E22D7DA00BDF5EF /* TodoList */, @@ -4145,6 +4166,7 @@ 11ADF93D267D34A20040A7E3 /* NotificationCommands */ = { isa = PBXGroup; children = ( + A95FDD0E2F6B8A19008EF72F /* HandlerLiveActivity.swift */, 11ADF93E267D34AD0040A7E3 /* NotificationsCommandManager.swift */, ); path = NotificationCommands; @@ -6468,6 +6490,16 @@ path = Overlay; sourceTree = ""; }; + 8881A40916A3424685660E6B /* LiveActivity */ = { + isa = PBXGroup; + children = ( + 5E95733B72864AB3B9607B57 /* MockLiveActivityRegistry.swift */, + B58D524991C142DBB38A1968 /* HandlerLiveActivityTests.swift */, + D790D5FA5DBB4B5B9DBB2334 /* NotificationsCommandManagerLiveActivityTests.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; 95A32294D4A340198B769AAB /* CommonlyUsedEntities */ = { isa = PBXGroup; children = ( @@ -6490,6 +6522,33 @@ path = Configuration; sourceTree = ""; }; + A95FDD0B2F6B89C6008EF72F /* LiveActivity */ = { + isa = PBXGroup; + children = ( + A95FDD092F6B89C6008EF72F /* HALiveActivityAttributes.swift */, + A95FDD0A2F6B89C6008EF72F /* LiveActivityRegistry.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; + A95FDD132F6B8A3E008EF72F /* LiveActivity */ = { + isa = PBXGroup; + children = ( + A95FDD102F6B8A3E008EF72F /* HADynamicIslandView.swift */, + A95FDD112F6B8A3E008EF72F /* HALiveActivityConfiguration.swift */, + A95FDD122F6B8A3E008EF72F /* HALockScreenView.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; + A95FDD182F6B8A5B008EF72F /* LiveActivity */ = { + isa = PBXGroup; + children = ( + A95FDD172F6B8A5B008EF72F /* LiveActivitySettingsView.swift */, + ); + path = LiveActivity; + sourceTree = ""; + }; AAB60FA4DE371AD957F6907B /* Pods */ = { isa = PBXGroup; children = ( @@ -6844,6 +6903,7 @@ B661FB6B226BCC8500E541DD /* Settings */ = { isa = PBXGroup; children = ( + A95FDD182F6B8A5B008EF72F /* LiveActivity */, 4257EA912F1790DE00D81506 /* EntityPicker */, 429AFE5A2DB7BE3200AF0836 /* General */, 4211551F2D3525E800A71630 /* AppIcon */, @@ -7097,6 +7157,7 @@ D03D891820E0A85300D4F28D /* Shared */ = { isa = PBXGroup; children = ( + A95FDD0B2F6B89C6008EF72F /* LiveActivity */, 42BB4C362CD26490003E47FD /* HATypedRequest+App.swift */, 4236229F2F05587800391BD0 /* EntityIconColorProvider.swift */, 429C33C02F17A6CF0033EF5E /* Models */, @@ -7163,6 +7224,7 @@ 428626002DA5CC8400D58D13 /* DesignSystem */, 118511C024B25BDC00D18F60 /* Webhook */, 11AF4D28249D88B2006C74C0 /* Sensors */, + 8881A40916A3424685660E6B /* LiveActivity */, D0A6367120DB7D1100E5C49B /* ClientEventTests.swift */, 11AD2EA8252900B500FBC437 /* Resources */, 11B7FD762493232400E60ED9 /* BackgroundTask.test.swift */, @@ -7170,10 +7232,10 @@ 11CB98CC249E637300B05222 /* Version+HA.test.swift */, 11883CC424C12C8A0036A6C6 /* CLLocation+Extensions.test.swift */, 11883CC624C131EE0036A6C6 /* RealmZone.test.swift */, - 892F0EF22A0B9F20AAEE4CCA /* Database/DatabaseMigration.test.swift */, - BC31518EE9DC9E065AC508D9 /* Database/DatabaseTableProtocol.test.swift */, - 5C50FA39BF16AD0BD782D0D7 /* Database/GRDB+Initialization.test.swift */, - 9EE9A0E08E6FEBDDE425D0D4 /* Database/TableSchemaTests.test.swift */, + 892F0EF22A0B9F20AAEE4CCA /* DatabaseMigration.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 */, @@ -7832,7 +7894,6 @@ }; 1171506824DFCDE60065E874 = { CreatedOnToolsVersion = 12.0; - DevelopmentTeam = QMQYCKL255; }; 11B6B57A2948F8E100B8B552 = { CreatedOnToolsVersion = 14.1; @@ -8034,7 +8095,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" */, 42E5E2E42F38CEA30030BBEB /* XCRemoteSwiftPackageReference "SwiftMessages" */, @@ -8564,14 +8625,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"; @@ -8709,14 +8766,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"; @@ -8752,14 +8805,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"; @@ -8859,14 +8908,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"; @@ -8977,6 +9022,9 @@ 42C1012A2CD3DB8A0012BA78 /* CoverIntent.swift in Sources */, 426CBB6C2C9C550D003CA3AC /* IntentSwitchEntity.swift in Sources */, 110E694424E77125004AA96D /* WidgetActionsProvider.swift in Sources */, + A95FDD142F6B8A3E008EF72F /* HADynamicIslandView.swift in Sources */, + A95FDD152F6B8A3E008EF72F /* HALiveActivityConfiguration.swift in Sources */, + A95FDD162F6B8A3E008EF72F /* HALockScreenView.swift in Sources */, 42BA1BC82C8864C200A2FC36 /* OpenPageAppIntent.swift in Sources */, 426CBB6A2C9C543F003CA3AC /* ControlSwitchValueProvider.swift in Sources */, 421F2BA12EF847AA00F21FE5 /* AutomationAppIntent.swift in Sources */, @@ -9160,6 +9208,7 @@ 4201605F2E79A68C00F68044 /* BaseOnboardingView.swift in Sources */, 403AE92A2C2F3A9200D48147 /* IntentServerAppEntitiy.swift in Sources */, 1101568524D770B2009424C9 /* NFCReader.swift in Sources */, + A95FDD192F6B8A5B008EF72F /* LiveActivitySettingsView.swift in Sources */, 1185DF9A271FE60F00ED7D9A /* OnboardingAuthStep.swift in Sources */, 420FE8502B556F7500878E06 /* CarPlayEntitiesListTemplate+Build.swift in Sources */, 42B95B522BE007E30070F2D4 /* SafeScriptMessageHandler.swift in Sources */, @@ -9992,6 +10041,8 @@ 42F5CAE72B10CDC900409816 /* CardView.swift in Sources */, 4251AAC02C6CE376004CCC9D /* MagicItem.swift in Sources */, 115560E827011E3300A8F818 /* HAPanel.swift in Sources */, + A95FDD0C2F6B89C6008EF72F /* LiveActivityRegistry.swift in Sources */, + A95FDD0D2F6B89C6008EF72F /* HALiveActivityAttributes.swift in Sources */, 420CFC812D3F9D89009A94F3 /* DatabaseTables.swift in Sources */, 11C9E43B2505B04E00492A88 /* HACoreAudioObjectSystem.swift in Sources */, D0C88464211F33CE00CCB501 /* TokenManager.swift in Sources */, @@ -10038,6 +10089,7 @@ 11B38EEA275C54A200205C7B /* PickAServerError.swift in Sources */, B6B74CBD228399AB00D58A68 /* Action.swift in Sources */, 428DC00A2F0CAAE7003B08D5 /* EntityProvider+Details.swift in Sources */, + A95FDD0F2F6B8A19008EF72F /* HandlerLiveActivity.swift in Sources */, 11CB98CA249E62E700B05222 /* Version+HA.swift in Sources */, 420F53EA2C4E9D54003C8415 /* WidgetsKind.swift in Sources */, 11EE9B4924C5116F00404AF8 /* LegacyModelManager.swift in Sources */, @@ -10282,10 +10334,10 @@ 11AF4D2C249D965C006C74C0 /* BatterySensor.test.swift in Sources */, 11F2F2B8258728B200F61F7C /* NotificationAttachmentParserURL.test.swift in Sources */, 11883CC724C131EE0036A6C6 /* RealmZone.test.swift in Sources */, - DA6F4C18D66EDBA5DCEAE833 /* Database/DatabaseMigration.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 */, + 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 */, @@ -10328,6 +10380,9 @@ 421125C72F51DE9200971BAD /* StatePrecision.test.swift in Sources */, 114CBAED283AB92D00A9BAFF /* SecTrust+TestAdditions.swift in Sources */, 110ED58025A570F100489AF7 /* DisplaySensor.test.swift in Sources */, + 618FCE5CA6B34267BB2056F5 /* MockLiveActivityRegistry.swift in Sources */, + 71E0BF803A854C3B9F0CB726 /* HandlerLiveActivityTests.swift in Sources */, + 897D7538631C46D4BD849CF5 /* NotificationsCommandManagerLiveActivityTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -12167,7 +12222,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */ = { + 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */ = { isa = XCLocalSwiftPackageReference; relativePath = Sources/SharedPush; }; @@ -12239,7 +12294,7 @@ }; 4273F7DF2E258827000629F7 /* SharedPush */ = { isa = XCSwiftPackageProductDependency; - package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "Sources/SharedPush" */; + package = 42E00D0F2E1E7487006D140D /* XCLocalSwiftPackageReference "SharedPush" */; productName = SharedPush; }; 427692E22B98B82500F24321 /* SharedPush */ = { diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index efdd247ca3..f27aba01db 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -90,6 +90,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // swiftlint:enable prohibit_environment_assignment notificationManager.setupNotifications() + setupLiveActivityReattachment() setupFirebase() setupModels() setupLocalization() @@ -372,6 +373,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate { }) } + private func setupLiveActivityReattachment() { + #if canImport(ActivityKit) + if #available(iOS 17.2, *) { + // Pre-warm the registry on the main thread before spawning background Tasks. + // This avoids a lazy-init race if a push notification handler accesses it + // concurrently from a background thread. + guard let registry = Current.liveActivityRegistry else { return } + + Task { + // Re-attach observation tasks (push token + lifecycle) to any Live Activities + // that survived the previous process termination. Must run before the first + // notification handler fires so no push token updates are missed. + await registry.reattach() + } + + // Begin observing the push-to-start token stream on a separate Task. + // The stream is infinite; this Task is kept alive for the app's lifetime. + Task { + await registry.startObservingPushToStartToken() + } + } + #endif + } + private func setupFirebase() { let optionsFile: String = { switch Current.appConfiguration { diff --git a/Sources/App/Notifications/NotificationManager.swift b/Sources/App/Notifications/NotificationManager.swift index 8294b93302..c5a0101272 100644 --- a/Sources/App/Notifications/NotificationManager.swift +++ b/Sources/App/Notifications/NotificationManager.swift @@ -10,11 +10,15 @@ import XCGLogger class NotificationManager: NSObject, LocalPushManagerDelegate { lazy var localPushManager: NotificationManagerLocalPushInterface = { + #if targetEnvironment(simulator) + return NotificationManagerLocalPushInterfaceDirect(delegate: self) + #else if Current.isCatalyst { return NotificationManagerLocalPushInterfaceDirect(delegate: self) } else { return NotificationManagerLocalPushInterfaceExtension() } + #endif }() var commandManager = NotificationCommandManager() @@ -281,6 +285,28 @@ extension NotificationManager: UNUserNotificationCenterDelegate { ) { Messaging.messaging().appDidReceiveMessage(notification.request.content.userInfo) + // Handle commands (including Live Activities) for foreground notifications. + // didReceiveRemoteNotification handles background pushes via Firebase/APNs, + // but willPresent fires when the app is in the foreground. Without this, + // notifications received while the app is open would never trigger the + // Live Activity handler. + // If a command is recognized, suppress the notification banner so the user + // sees only the Live Activity (not a duplicate standard notification). + if let hadict = notification.request.content.userInfo["homeassistant"] as? [String: Any], + (hadict["command"] as? String) != nil || (hadict["live_update"] as? Bool) == true { + commandManager.handle(notification.request.content.userInfo).done { + completionHandler([]) + }.catch { error in + // Unknown command — fall through to normal banner presentation so the user isn't silently swallowed. + if case NotificationCommandManager.CommandError.unknownCommand = error { + completionHandler([.badge, .sound, .list, .banner]) + } else { + completionHandler([]) + } + } + return + } + if notification.request.content.userInfo[XCGLogger.notifyUserInfoKey] != nil, UIApplication.shared.applicationState != .background { completionHandler([]) diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index 02e6a7a3c1..ba7146e8ae 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -129,6 +129,10 @@ We use Siri to allow created shortcuts to interact with the app. NSSpeechRecognitionUsageDescription Used to dictate text to Assist. + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + NSUserActivityTypes AssistInAppIntent diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index c4833f6983..7c7e6df0c7 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -500,6 +500,23 @@ This server requires a client certificate (mTLS) but the operation was cancelled "kiosk.security.taps_required" = "Taps Required: %li"; "kiosk.title" = "Kiosk Mode"; "legacy_actions.disclaimer" = "Legacy iOS Actions are not the recommended way to interact with Home Assistant anymore, please use Scripts, Scenes and Automations directly in your Widgets, Apple Watch and CarPlay."; + +"live_activity.title" = "Live Activities"; +"live_activity.subtitle" = "Real-time Home Assistant updates on your Lock Screen and Dynamic Island."; +"live_activity.section.active" = "Active Activities"; +"live_activity.section.status" = "Status"; +"live_activity.section.privacy" = "Privacy"; +"live_activity.empty_state" = "No active Live Activities"; +"live_activity.status.enabled" = "Enabled"; +"live_activity.status.not_supported" = "Not available on iPad"; +"live_activity.status.open_settings" = "Open Settings"; +"live_activity.end_all.button" = "End All Activities"; +"live_activity.end_all.confirm.title" = "End all Live Activities?"; +"live_activity.end_all.confirm.button" = "End All"; +"live_activity.privacy.message" = "Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully."; +"live_activity.frequent_updates.title" = "Frequent Updates"; +"live_activity.frequent_updates.footer" = "Allows Home Assistant to update Live Activities up to once per second. Enable in Settings \u203A %@ \u203A Live Activities."; + "location_change_notification.app_shortcut.body" = "Location updated via App Shortcut"; "location_change_notification.background_fetch.body" = "Current location delivery triggered via background fetch"; "location_change_notification.beacon_region_enter.body" = "%@ entered via iBeacon"; diff --git a/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift new file mode 100644 index 0000000000..6a70154066 --- /dev/null +++ b/Sources/App/Settings/LiveActivity/LiveActivitySettingsView.swift @@ -0,0 +1,687 @@ +import ActivityKit +import Shared +import SwiftUI + +// MARK: - Entry point + +/// Deployment target is iOS 15. The settings item is filtered from the list on < iOS 17.2 +/// (see SettingsItem.allVisibleCases), so this view is only ever navigated to on iOS 17.2+. +@available(iOS 17.2, *) +struct LiveActivitySettingsView: View { + // MARK: State + + @State private var activities: [ActivitySnapshot] = [] + @State private var authorizationEnabled: Bool = false + @State private var frequentUpdatesEnabled: Bool = false + @State private var showEndAllConfirmation = false + + // MARK: Body + + var body: some View { + List { + AppleLikeListTopRowHeader( + image: .playBoxOutlineIcon, + title: L10n.LiveActivity.title, + subtitle: L10n.LiveActivity.subtitle + ) + + statusSection + + if activities.isEmpty { + Section(L10n.LiveActivity.Section.active) { + HStack { + Text(L10n.LiveActivity.emptyState) + .foregroundStyle(.secondary) + Spacer() + } + } + } else { + Section(L10n.LiveActivity.Section.active) { + ForEach(activities) { snapshot in + ActivityRow(snapshot: snapshot) { + endActivity(tag: snapshot.tag) + } + } + + Button(role: .destructive) { + showEndAllConfirmation = true + } label: { + Label(L10n.LiveActivity.EndAll.button, systemSymbol: .xmarkCircle) + } + .confirmationDialog( + L10n.LiveActivity.EndAll.Confirm.title, + isPresented: $showEndAllConfirmation, + titleVisibility: .visible + ) { + Button(L10n.LiveActivity.EndAll.Confirm.button, role: .destructive) { + endAllActivities() + } + Button(L10n.cancelLabel, role: .cancel) {} + } + } + } + + #if DEBUG + debugSection + #endif + + privacySection + + if #available(iOS 17.2, *) { + frequentUpdatesSection + } + } + .navigationTitle(L10n.LiveActivity.title) + .task { await loadActivities() } + } + + // MARK: - Sections + + private var statusSection: some View { + Section(L10n.LiveActivity.Section.status) { + HStack { + Label(L10n.LiveActivity.title, systemSymbol: .livephoto) + Spacer() + if authorizationEnabled { + Text(L10n.LiveActivity.Status.enabled) + .foregroundStyle(.green) + } else if UIDevice.current.userInterfaceIdiom == .pad { + Text(L10n.LiveActivity.Status.notSupported) + .foregroundStyle(.secondary) + } else { + Button(L10n.LiveActivity.Status.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .foregroundStyle(.orange) + } + } + } + } + + // MARK: - Debug (DEBUG builds only) + + // + // Two sections: Static (fixed snapshots to verify layout) and Animated (multi-stage + // self-updating sequences to simulate real HA automation behavior). + // + // Each scenario tests a unique combination of ContentState fields so they can be + // run independently without duplicating coverage. + // + // HOW TO USE: + // 1. Tap any button to start the activity. + // 2. Tap Allow on the permission prompt. + // 3. Lock the simulator immediately (Device menu → Lock, or ⌘L). + // 4. Watch the lock screen — animated scenarios update themselves automatically. + // 5. End individual activities via the × button in the Active section above. + // + // NOTE: criticalText is only visible in the Dynamic Island compact trailing slot. + // It does NOT appear on the lock screen. Use a Dynamic Island device or + // simulator (iPhone 14 Pro+) to see it. + + #if DEBUG + private var debugSection: some View { + Group { + Section { + // Minimum viable layout — only the message field is set. + // Verifies the bare layout renders without icon, progress, or timer. + Button("Plain Message") { + startTestActivity( + tag: "debug-plain", + title: "Home Assistant", + state: .init(message: "Everything looks good at home.") + ) + } + + // icon = nil code path. Layout must not shift or break when no icon is provided. + // color = nil so the progress bar uses the default HA-blue tint. + // criticalText ("Active") visible in DI compact trailing only. + Button("No Icon · Default Color") { + startTestActivity( + tag: "debug-no-icon", + title: "Script Running", + state: .init( + message: "Irrigation zone 3 is active", + criticalText: "Active", + progress: 35, + progressMax: 100 + ) + ) + } + + // Short 60-second countdown with no progress bar. + // Red color communicates urgency. Watch the timer count down in real time. + // Represents automations like alarm arming delays or reminder countdowns. + Button("Alarm · 60 sec Countdown") { + startTestActivity( + tag: "debug-alarm", + title: "Security Alarm", + state: .init( + message: "Motion at back door · Arms in 60 seconds", + criticalText: "60 sec", + chronometer: true, + countdownEnd: Date().addingTimeInterval(60), + icon: "mdi:alarm-light", + color: "#F44336" + ) + ) + } + + // Every ContentState field active at the same time. + // Lock screen shows: icon → live countdown → progress bar. + // criticalText ("5 min") visible in DI compact trailing only. + // Use this to confirm no layout collisions when all fields are populated. + Button("All Fields · Max Load") { + startTestActivity( + tag: "debug-all", + title: "All Fields", + state: .init( + message: "All content state fields active", + criticalText: "5 min", + progress: 42, + progressMax: 100, + chronometer: true, + countdownEnd: Date().addingTimeInterval(5 * 60), + icon: "mdi:home-assistant", + color: "#03A9F4" + ) + ) + } + } header: { + Text("Debug · Static") + } footer: { + Text("Fixed state — no updates after start. Good for checking layout at a glance.") + } + + Section { + // Progress bar advances through five named stages. + // criticalText tracks the current stage name in the DI compact trailing slot. + // Icon swaps from washing-machine to check-circle on the final update. + // Represents any multi-step appliance cycle automation. + Button("Washing Machine · Stage Labels (~12 s)") { startWashingMachineCycle() } + + // Numeric percentage in criticalText updates alongside the progress bar. + // Color shifts from green to yellow-green as the charge nears 100 %. + // Represents any "% complete with time remaining" automation pattern. + Button("EV Charging · Numeric % (~16 s)") { startEVChargingSimulation() } + + // The only scenario where both progress (playback position) and a live countdown + // (time remaining in track) are active and updating at the same time. + // Simulates a track change mid-sequence: progress resets, countdown resets. + Button("Media Player · Progress + Timer (~20 s)") { startMediaNowPlaying() } + + // Message, criticalText, and icon all change on every update — no progress bar. + // Represents automations where the status category itself changes (not just a value). + Button("Package Delivery · All Text Fields (~15 s)") { startPackageJourney() } + + // No progress bar — state communicated entirely through color and icon. + // Escalates orange (motion) → red (person) → green (all clear). + // Represents any alert-and-resolve automation pattern. + Button("Security Escalation · Color + Icon (~8 s)") { startSecuritySequence() } + + // Cycles through wash stages then calls activity.end() with .default dismissal. + // The only scenario that tests the full lifecycle: start → update → end. + // After ending, the final "Done" state lingers on the lock screen (up to 4 h). + Button("Dishwasher · Full Lifecycle, Ends Itself (~12 s)") { startDishwasherAutoComplete() } + + // Fires 6 updates 2 seconds apart (12 s total). + // On iOS 18 the system enforces ~15 s between rendered updates — some will be + // silently dropped. Watch the counter skip values to see the rate limit in action. + // On the simulator and iOS 17 all 6 updates should render. + Button("Rate Limit · 6 Rapid Updates, 2 s Apart (~12 s)") { startRapidUpdateStressTest() } + } header: { + Text("Debug · Animated") + } footer: { + Text( + "Activity updates itself after you tap. Tap, then immediately lock (⌘L) " + + "to watch updates on the lock screen in real time." + ) + } + } + } + #endif + + #if DEBUG + + // MARK: - Debug helpers + + /// Starts a single-state activity (no subsequent updates). + private func startTestActivity(tag: String, title: String, state: HALiveActivityAttributes.ContentState) { + Task { + let attributes = HALiveActivityAttributes(tag: tag, title: title) + _ = try? Activity.request( + attributes: attributes, + content: ActivityContent(state: state, staleDate: Date().addingTimeInterval(30 * 60)), + pushType: nil + ) + await loadActivities() + } + } + + /// Starts an activity and drives it through `stages` sequentially. + /// + /// - Parameters: + /// - stages: Array of `(delayAfterPrevious seconds, ContentState)`. The first entry's + /// delay is ignored — it becomes the initial content. Each subsequent entry waits + /// `delay` seconds after the previous stage before pushing the update. + /// - endAfterCompletion: When `true`, calls `activity.end()` with `.default` dismissal + /// after the final stage, leaving the last state visible on the lock screen (up to 4 h). + private func startAnimatedActivity( + tag: String, + title: String, + stages: [(delay: Double, state: HALiveActivityAttributes.ContentState)], + endAfterCompletion: Bool = false + ) { + guard let first = stages.first else { return } + Task { + let attributes = HALiveActivityAttributes(tag: tag, title: title) + guard let activity = try? Activity.request( + attributes: attributes, + content: ActivityContent(state: first.state, staleDate: Date().addingTimeInterval(30 * 60)), + pushType: nil + ) else { return } + await loadActivities() + for stage in stages.dropFirst() { + try? await Task.sleep(nanoseconds: UInt64(stage.delay * 1_000_000_000)) + await activity.update(ActivityContent( + state: stage.state, + staleDate: Date().addingTimeInterval(30 * 60) + )) + await loadActivities() + } + if endAfterCompletion, let last = stages.last { + await activity.end( + ActivityContent(state: last.state, staleDate: Date().addingTimeInterval(30 * 60)), + dismissalPolicy: .default + ) + await loadActivities() + } + } + } + + // MARK: - Animated scenario implementations + + /// Progress advances through five named wash stages. + /// criticalText tracks the stage name (DI compact trailing). + /// Icon swaps to check-circle on the final update. + private func startWashingMachineCycle() { + startAnimatedActivity( + tag: "debug-washing", + title: "Washing Machine", + stages: [ + (0, .init( + message: "Starting soak", + criticalText: "Soak", + progress: 5, progressMax: 100, + icon: "mdi:washing-machine", color: "#2196F3" + )), + (3, .init( + message: "Washing · Heavy cycle", + criticalText: "Wash", + progress: 30, progressMax: 100, + icon: "mdi:washing-machine", color: "#2196F3" + )), + (3, .init( + message: "Rinsing · 1 of 2", + criticalText: "Rinse", + progress: 60, progressMax: 100, + icon: "mdi:washing-machine", color: "#2196F3" + )), + (3, .init( + message: "Final spin", + criticalText: "Spin", + progress: 85, progressMax: 100, + icon: "mdi:washing-machine", color: "#2196F3" + )), + (3, .init( + message: "Cycle complete", + criticalText: "Done", + progress: 100, progressMax: 100, + icon: "mdi:check-circle", color: "#4CAF50" + )), + ] + ) + } + + /// Numeric percentage in criticalText updates alongside the progress bar. + /// Color shifts from green to yellow-green as the charge nears full. + private func startEVChargingSimulation() { + startAnimatedActivity( + tag: "debug-ev", + title: "EV Charging", + stages: [ + (0, .init( + message: "Charging · Est. 45 min remaining", + criticalText: "45%", + progress: 45, progressMax: 100, + icon: "mdi:ev-station", color: "#4CAF50" + )), + (4, .init( + message: "Charging · Est. 30 min remaining", + criticalText: "60%", + progress: 60, progressMax: 100, + icon: "mdi:ev-station", color: "#4CAF50" + )), + (4, .init( + message: "Charging · Est. 15 min remaining", + criticalText: "78%", + progress: 78, progressMax: 100, + icon: "mdi:ev-station", color: "#8BC34A" + )), + (4, .init( + message: "Charge complete", + criticalText: "Full", + progress: 100, progressMax: 100, + icon: "mdi:battery-charging", color: "#4CAF50" + )), + ] + ) + } + + /// Both progress (playback position) and a live countdown (time remaining) update together. + /// countdownEnd is fixed once at tap time so the timer runs smoothly across all stages. + /// Simulates a track change: progress resets and countdownEnd resets on the final stage. + private func startMediaNowPlaying() { + let track1End = Date().addingTimeInterval(2 * 60) + startAnimatedActivity( + tag: "debug-media", + title: "Now Playing", + stages: [ + (0, .init( + message: "Bohemian Rhapsody · Queen", + criticalText: "1 / 12", + progress: 20, progressMax: 100, + chronometer: true, countdownEnd: track1End, + icon: "mdi:music-note", color: "#9C27B0" + )), + (5, .init( + message: "Bohemian Rhapsody · Queen", + criticalText: "1 / 12", + progress: 42, progressMax: 100, + chronometer: true, countdownEnd: track1End, + icon: "mdi:music-note", color: "#9C27B0" + )), + (5, .init( + message: "Bohemian Rhapsody · Queen", + criticalText: "1 / 12", + progress: 67, progressMax: 100, + chronometer: true, countdownEnd: track1End, + icon: "mdi:music-note", color: "#9C27B0" + )), + // Track changes — message, progress, and countdownEnd all reset together. + (5, .init( + message: "Don't Stop Me Now · Queen", + criticalText: "2 / 12", + progress: 8, progressMax: 100, + chronometer: true, countdownEnd: Date().addingTimeInterval(3 * 60 + 29), + icon: "mdi:music-note", color: "#9C27B0" + )), + ] + ) + } + + /// Message, criticalText, and icon all change on every update — no progress bar. + /// Represents automations where the status category itself changes, not just a value. + private func startPackageJourney() { + startAnimatedActivity( + tag: "debug-delivery", + title: "Package Delivery", + stages: [ + (0, .init( + message: "Order shipped · Est. today", + criticalText: "Shipped", + icon: "mdi:package-variant-closed", color: "#795548" + )), + (5, .init( + message: "Out for delivery · 8 stops away", + criticalText: "On way", + icon: "mdi:truck-delivery", color: "#FF9800" + )), + (5, .init( + message: "Nearby · 2 stops away", + criticalText: "Nearby", + icon: "mdi:truck-delivery", color: "#FF5722" + )), + (5, .init( + message: "Delivered to front door", + criticalText: "Done", + icon: "mdi:package-variant", color: "#4CAF50" + )), + ] + ) + } + + /// State communicated through color and icon only — no progress bar. + /// Escalates orange → red → green to show the alert-and-resolve pattern. + private func startSecuritySequence() { + startAnimatedActivity( + tag: "debug-security", + title: "Security Alert", + stages: [ + (0, .init( + message: "Motion detected at front door", + criticalText: "Motion", + icon: "mdi:motion-sensor", color: "#FF9800" + )), + (4, .init( + message: "Person detected · Camera 1", + criticalText: "Person", + icon: "mdi:cctv", color: "#F44336" + )), + (4, .init( + message: "Disarmed · All clear", + criticalText: "Safe", + icon: "mdi:shield-check", color: "#4CAF50" + )), + ] + ) + } + + /// Cycles through wash stages then calls activity.end() with .default dismissal. + /// After ending, the "Done" state lingers on the lock screen for up to 4 hours — + /// this is the expected UX for any automation that represents a completed task. + private func startDishwasherAutoComplete() { + startAnimatedActivity( + tag: "debug-dishwasher", + title: "Dishwasher", + stages: [ + (0, .init( + message: "Pre-wash in progress", + criticalText: "Pre-wash", + progress: 20, progressMax: 100, + icon: "mdi:dishwasher", color: "#26C6DA" + )), + (3, .init( + message: "Main wash · Hot cycle", + criticalText: "Wash", + progress: 50, progressMax: 100, + icon: "mdi:dishwasher", color: "#26C6DA" + )), + (3, .init( + message: "Rinse and dry", + criticalText: "Rinse", + progress: 80, progressMax: 100, + icon: "mdi:dishwasher", color: "#26C6DA" + )), + (3, .init( + message: "Dishes are clean", + criticalText: "Done", + progress: 100, progressMax: 100, + icon: "mdi:check-circle", color: "#4CAF50" + )), + ], + endAfterCompletion: true + ) + } + + /// Fires 6 updates spaced 2 seconds apart (12 s total). + /// On iOS 18 the system enforces ~15 s between rendered updates — excess updates are + /// silently dropped and the counter will appear to skip values on device. + /// On the simulator and iOS 17 all 6 updates should render without skipping. + private func startRapidUpdateStressTest() { + startAnimatedActivity( + tag: "debug-rapid", + title: "Rate Limit Test", + stages: [ + (0, .init( + message: "Update 1 of 6 · Watch for skipped values on device", + criticalText: "#1", + progress: 0, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 2 of 6", + criticalText: "#2", + progress: 17, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 3 of 6", + criticalText: "#3", + progress: 33, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 4 of 6", + criticalText: "#4", + progress: 50, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 5 of 6", + criticalText: "#5", + progress: 67, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + (2, .init( + message: "Update 6 of 6 · All done", + criticalText: "#6", + progress: 100, progressMax: 100, + icon: "mdi:lightning-bolt", color: "#FF9800" + )), + ] + ) + } + + #endif + + private var privacySection: some View { + Section { + Label(L10n.LiveActivity.Privacy.message, systemSymbol: .lockShield) + .font(.footnote) + .foregroundStyle(.secondary) + } header: { + Text(L10n.LiveActivity.Section.privacy) + } + } + + @available(iOS 17.2, *) + private var frequentUpdatesSection: some View { + let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "Home Assistant" + return Section { + HStack { + Label(L10n.LiveActivity.FrequentUpdates.title, systemSymbol: .bolt) + Spacer() + if frequentUpdatesEnabled { + Text(L10n.LiveActivity.Status.enabled) + .foregroundStyle(.green) + } else { + Button(L10n.LiveActivity.Status.openSettings) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .foregroundStyle(.secondary) + } + } + } header: { + Text(L10n.LiveActivity.FrequentUpdates.title) + } footer: { + Text(L10n.LiveActivity.FrequentUpdates.footer(appName)) + } + } + + // MARK: - Data + + private func loadActivities() async { + let info = ActivityAuthorizationInfo() + authorizationEnabled = info.areActivitiesEnabled + if #available(iOS 17.2, *) { + frequentUpdatesEnabled = info.frequentPushesEnabled + } + + activities = Activity.activities.map { + ActivitySnapshot(activity: $0) + } + } + + private func endActivity(tag: String) { + Task { + await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: .immediate) + await loadActivities() + } + } + + private func endAllActivities() { + Task { + let tags = activities.map(\.tag) + await withTaskGroup(of: Void.self) { group in + for tag in tags { + group.addTask { + await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: .immediate) + } + } + } + await loadActivities() + } + } +} + +// MARK: - Activity row + +@available(iOS 17.2, *) +private struct ActivityRow: View { + let snapshot: ActivitySnapshot + let onEnd: () -> Void + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.title) + .font(.body) + Text(snapshot.message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Text("tag: \(snapshot.tag)") + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer() + Button(role: .destructive, action: onEnd) { + Image(systemSymbol: .xmarkCircleFill) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + } +} + +// MARK: - Snapshot model + +@available(iOS 17.2, *) +private struct ActivitySnapshot: Identifiable { + let id: String + let tag: String + let title: String + let message: String + + init(activity: Activity) { + self.id = activity.id + self.tag = activity.attributes.tag + self.title = activity.attributes.title + self.message = activity.content.state.message + } +} diff --git a/Sources/App/Settings/Settings/SettingsItem.swift b/Sources/App/Settings/Settings/SettingsItem.swift index 5e71a52ab3..bb352dd3d8 100644 --- a/Sources/App/Settings/Settings/SettingsItem.swift +++ b/Sources/App/Settings/Settings/SettingsItem.swift @@ -8,6 +8,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case kiosk case location case notifications + case liveActivities case sensors case nfc case widgets @@ -28,6 +29,7 @@ enum SettingsItem: String, Hashable, CaseIterable { case .kiosk: return L10n.Kiosk.title case .location: return L10n.Settings.DetailsSection.LocationSettingsRow.title case .notifications: return L10n.Settings.DetailsSection.NotificationSettingsRow.title + case .liveActivities: return L10n.LiveActivity.title case .sensors: return L10n.SettingsSensors.title case .nfc: return L10n.Nfc.List.title case .widgets: return L10n.Settings.Widgets.title @@ -57,6 +59,8 @@ enum SettingsItem: String, Hashable, CaseIterable { MaterialDesignIconsImage(icon: .crosshairsGpsIcon, size: 24) case .notifications: MaterialDesignIconsImage(icon: .bellOutlineIcon, size: 24) + case .liveActivities: + MaterialDesignIconsImage(icon: .playBoxOutlineIcon, size: 24) case .sensors: MaterialDesignIconsImage(icon: .formatListBulletedIcon, size: 24) case .nfc: @@ -107,6 +111,10 @@ enum SettingsItem: String, Hashable, CaseIterable { SettingsLocationView() case .notifications: SettingsNotificationsView() + case .liveActivities: + if #available(iOS 17.2, *) { + LiveActivitySettingsView() + } case .sensors: SensorListView() case .nfc: @@ -143,12 +151,21 @@ enum SettingsItem: String, Hashable, CaseIterable { return false } #endif + // Live Activities require iOS 17.2+ and TestFlight + if item == .liveActivities { + if #available(iOS 17.2, *) { return Current.isTestFlight } + return false + } return true } } static var generalItems: [SettingsItem] { - [.general, .gestures, .kiosk, .location, .notifications] + var items: [SettingsItem] = [.general, .gestures, .kiosk, .location, .notifications] + if #available(iOS 17.2, *), Current.isTestFlight { + items.append(.liveActivities) + } + return items } static var integrationItems: [SettingsItem] { diff --git a/Sources/App/Settings/Settings/SettingsView.swift b/Sources/App/Settings/Settings/SettingsView.swift index 3dc3fab58d..42b82ec5a2 100644 --- a/Sources/App/Settings/Settings/SettingsView.swift +++ b/Sources/App/Settings/Settings/SettingsView.swift @@ -246,7 +246,7 @@ struct SettingsView: View { Label { HStack(spacing: DesignSystem.Spaces.one) { Text(item.title) - if item == .kiosk { + if item == .kiosk || item == .liveActivities { LabsLabel() } } diff --git a/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift new file mode 100644 index 0000000000..469bfecfbd --- /dev/null +++ b/Sources/Extensions/Widgets/LiveActivity/HADynamicIslandView.swift @@ -0,0 +1,163 @@ +import ActivityKit +import Shared +import SwiftUI +import WidgetKit + +// MARK: - DynamicIsland builder + +/// Builds the `DynamicIsland` for a Home Assistant Live Activity. +/// Used in `HALiveActivityConfiguration`'s `dynamicIsland:` closure. +@available(iOS 17.2, *) +func makeHADynamicIsland( + attributes: HALiveActivityAttributes, + state: HALiveActivityAttributes.ContentState +) -> DynamicIsland { + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HADynamicIslandIconView(slug: state.icon, color: state.color, size: 24) + .padding(.leading, DesignSystem.Spaces.half) + } + DynamicIslandExpandedRegion(.center) { + Text(attributes.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.trailing) { + HAExpandedTrailingView(state: state) + .padding(.trailing, DesignSystem.Spaces.half) + } + DynamicIslandExpandedRegion(.bottom) { + HAExpandedBottomView(state: state) + .padding(.horizontal, DesignSystem.Spaces.one) + .padding(.bottom, DesignSystem.Spaces.half) + } + } compactLeading: { + HADynamicIslandIconView(slug: state.icon, color: state.color, size: 16) + .padding(.leading, DesignSystem.Spaces.half) + } compactTrailing: { + HACompactTrailingView(state: state) + .padding(.trailing, DesignSystem.Spaces.half) + } minimal: { + HADynamicIslandIconView(slug: state.icon, color: state.color, size: 14) + } +} + +// MARK: - Icon view + +@available(iOS 17.2, *) +struct HADynamicIslandIconView: View { + let slug: String? + let color: String? + let size: CGFloat + + /// Hex string for Home Assistant brand blue — used for UIColor(hex:) fallback. + private static let haBlueHex = "#03A9F4" + + var body: some View { + if let slug { + // UIColor(hex:) from Shared handles nil/CSS names/3-6-8 digit hex; non-failable. + let uiColor = UIColor(hex: color ?? Self.haBlueHex) + let mdiIcon = MaterialDesignIcons(serversideValueNamed: slug) + Image(uiImage: mdiIcon.image( + ofSize: .init(width: size, height: size), + color: uiColor + )) + .resizable() + .frame(width: size, height: size) + } + } +} + +// MARK: - Compact trailing + +@available(iOS 17.2, *) +struct HACompactTrailingView: View { + let state: HALiveActivityAttributes.ContentState + + /// Fixed width for the countdown timer text in compact trailing. + /// 44 pt fits "M:SS" at caption2 size and prevents the Dynamic Island from + /// squeezing the slot narrower than the text needs. + private static let compactTrailingTimerWidth: CGFloat = 44 + /// Maximum width for non-timer compact trailing content (criticalText, progress %). + private static let compactTrailingMaxWidth: CGFloat = 50 + + var body: some View { + if state.chronometer == true, let end = state.countdownEnd { + Text(timerInterval: Date.now ... end, countsDown: true) + .font(.caption2) + .foregroundStyle(.white) + .monospacedDigit() + .contentTransition(.numericText(countsDown: true)) + .frame(width: Self.compactTrailingTimerWidth) + } else if let critical = state.criticalText { + Text(critical) + .font(.caption2) + .foregroundStyle(.white) + .lineLimit(1) + .frame(maxWidth: Self.compactTrailingMaxWidth) + } else if let fraction = state.progressFraction { + Text("\(Int(fraction * 100))%") + .font(.caption2) + .foregroundStyle(.white) + .monospacedDigit() + } + } +} + +// MARK: - Expanded trailing + +@available(iOS 17.2, *) +struct HAExpandedTrailingView: View { + let state: HALiveActivityAttributes.ContentState + + var body: some View { + if let fraction = state.progressFraction { + Text("\(Int(fraction * 100))%") + .font(.caption2) + .foregroundStyle(.white) + .monospacedDigit() + } else if let critical = state.criticalText { + Text(critical) + .font(.caption2) + .foregroundStyle(.white) + .lineLimit(1) + } + } +} + +// MARK: - Expanded bottom + +@available(iOS 17.2, *) +struct HAExpandedBottomView: View { + let state: HALiveActivityAttributes.ContentState + + var body: some View { + VStack(spacing: DesignSystem.Spaces.half) { + if state.chronometer == true, let end = state.countdownEnd { + Text(timerInterval: Date.now ... end, countsDown: true) + .font(.body.monospacedDigit()) + .foregroundStyle(.white) + .contentTransition(.numericText(countsDown: true)) + } else { + Text(state.message) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(2) + } + + if let fraction = state.progressFraction { + ProgressView(value: fraction) + .tint(accentColor) + } + } + } + + /// Accent color from ContentState, fallback to Home Assistant primary blue. + private var accentColor: Color { + if let hex = state.color { + return Color(hex: hex) + } + return .haPrimary + } +} diff --git a/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift b/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift new file mode 100644 index 0000000000..fc75a5d2c2 --- /dev/null +++ b/Sources/Extensions/Widgets/LiveActivity/HALiveActivityConfiguration.swift @@ -0,0 +1,23 @@ +import ActivityKit +import Shared +import SwiftUI +import WidgetKit + +@available(iOS 17.2, *) +struct HALiveActivityConfiguration: Widget { + /// Semi-transparent dark background for the Lock Screen presentation. + private static let lockScreenBackground = Color.black.opacity(0.75) + + var body: some WidgetConfiguration { + ActivityConfiguration(for: HALiveActivityAttributes.self) { context in + HALockScreenView( + attributes: context.attributes, + state: context.state + ) + .activityBackgroundTint(Self.lockScreenBackground) + .activitySystemActionForegroundColor(Color.white) + } dynamicIsland: { context in + makeHADynamicIsland(attributes: context.attributes, state: context.state) + } + } +} diff --git a/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift new file mode 100644 index 0000000000..72f3552c0a --- /dev/null +++ b/Sources/Extensions/Widgets/LiveActivity/HALockScreenView.swift @@ -0,0 +1,85 @@ +import ActivityKit +import Shared +import SwiftUI +import WidgetKit + +/// Lock Screen (and StandBy) view for a Home Assistant Live Activity. +/// +/// The system hard-truncates at 160 points height — padding counts against this limit. +/// Keep layout tight and avoid decorative spacing. +@available(iOS 17.2, *) +struct HALockScreenView: View { + let attributes: HALiveActivityAttributes + let state: HALiveActivityAttributes.ContentState + + /// Icon size for the MDI icon in the header row. + private static let iconSize: CGFloat = 20 + + /// Hex string for Home Assistant brand blue — used for UIColor(hex:) fallback. + private static let haBlueHex = "#03A9F4" + + /// Subdued white for secondary text (timer/message body). + private static let secondaryWhite: Color = .white.opacity(0.85) + + var body: some View { + VStack(alignment: .leading, spacing: DesignSystem.Spaces.half) { + // Header row: icon + title + HStack(spacing: DesignSystem.Spaces.one) { + iconView + Text(attributes.title) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(1) + } + + // Body: timer or message + if state.chronometer == true, let end = state.countdownEnd { + Text(timerInterval: Date.now ... end, countsDown: true) + .font(.subheadline) + .foregroundStyle(Self.secondaryWhite) + .monospacedDigit() + .contentTransition(.numericText(countsDown: true)) + } else { + Text(state.message) + .font(.subheadline) + .foregroundStyle(Self.secondaryWhite) + .lineLimit(2) + } + + // Progress bar (only when progress data is present) + if let fraction = state.progressFraction { + ProgressView(value: fraction) + .tint(accentColor) + } + } + .padding(.horizontal, DesignSystem.Spaces.two) + .padding(.vertical, DesignSystem.Spaces.oneAndHalf) + } + + // MARK: - Sub-views + + @ViewBuilder + private var iconView: some View { + if let iconSlug = state.icon { + // UIColor(hex:) from Shared handles CSS names and 3/6/8-digit hex; non-failable. + let uiColor = UIColor(hex: state.color ?? Self.haBlueHex) + let mdiIcon = MaterialDesignIcons(serversideValueNamed: iconSlug) + Image(uiImage: mdiIcon.image( + ofSize: .init(width: Self.iconSize, height: Self.iconSize), + color: uiColor + )) + .resizable() + .frame(width: Self.iconSize, height: Self.iconSize) + } + } + + // MARK: - Helpers + + /// Accent color from ContentState, fallback to Home Assistant primary blue. + private var accentColor: Color { + if let hex = state.color { + return Color(hex: hex) + } + return .haPrimary + } +} diff --git a/Sources/Extensions/Widgets/Widgets.swift b/Sources/Extensions/Widgets/Widgets.swift index 140b9fe9b6..a7a332cb7b 100644 --- a/Sources/Extensions/Widgets/Widgets.swift +++ b/Sources/Extensions/Widgets/Widgets.swift @@ -21,6 +21,9 @@ struct WidgetsBundleLegacy: WidgetBundle { } var body: some Widget { + if #available(iOSApplicationExtension 17.2, *) { + HALiveActivityConfiguration() + } WidgetAssist() LegacyWidgetActions() WidgetOpenPage() @@ -34,6 +37,9 @@ struct WidgetsBundle17: WidgetBundle { } var body: some Widget { + if #available(iOSApplicationExtension 17.2, *) { + HALiveActivityConfiguration() + } WidgetCommonlyUsedEntities() WidgetCustom() WidgetAssist() @@ -54,6 +60,8 @@ struct WidgetsBundle18: WidgetBundle { } var body: some Widget { + HALiveActivityConfiguration() + // Controls ControlAssist() ControlLight() diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 6c6f190f40..da9dbf82b8 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -11,6 +11,9 @@ import Version #if os(iOS) import Reachability #endif +#if os(iOS) && canImport(ActivityKit) +import ActivityKit +#endif public class HomeAssistantAPI { public enum APIError: Error, Equatable { @@ -64,7 +67,7 @@ public class HomeAssistantAPI { return "Home Assistant/\(appVersion) (\(bundle); build:\(appBuild); \(osNameVersion))" } - // "Mobile/BUILD_NUMBER" is what CodeMirror sniffs for to decide iOS or not; other things likely look for Safari + /// "Mobile/BUILD_NUMBER" is what CodeMirror sniffs for to decide iOS or not; other things likely look for Safari public static var applicationNameForUserAgent: String { HomeAssistantAPI.userAgent + " Mobile/HomeAssistant, like Safari" } @@ -358,7 +361,6 @@ public class HomeAssistantAPI { public func DownloadDataAt(url: URL, needsAuth: Bool) -> Promise { Promise { seal in - var finalURL = url let dataManager: Alamofire.Session = needsAuth ? self.manager : Self.unauthenticatedManager @@ -555,10 +557,31 @@ public class HomeAssistantAPI { private func mobileAppRegistrationRequestModel() -> MobileAppRegistrationRequest { with(MobileAppRegistrationRequest()) { if let pushID = Current.settingsStore.pushID { - $0.AppData = [ + var appData: [String: Any] = [ "push_url": "https://mobile-apps.home-assistant.io/api/sendPushNotification", "push_token": pushID, ] + + #if os(iOS) && canImport(ActivityKit) + if #available(iOS 17.2, *) { + // Advertise Live Activity support so HA can gate the UI and send + // activity push tokens back to the relay server. + // Use areActivitiesEnabled so iPad and users who disabled Live Activities + // in Settings correctly report false. + appData["supports_live_activities"] = ActivityAuthorizationInfo().areActivitiesEnabled + appData["supports_live_activities_frequent_updates"] = + ActivityAuthorizationInfo().frequentPushesEnabled + + // Push-to-start token (stored in Keychain at launch, updated via stream). + // The relay server uses this token to start a Live Activity entirely via APNs. + if let pushToStartToken = LiveActivityRegistry.storedPushToStartToken { + appData["live_activity_push_to_start_token"] = pushToStartToken + appData["live_activity_push_to_start_apns_environment"] = Current.apnsEnvironment + } + } + #endif + + $0.AppData = appData } $0.AppIdentifier = AppConstants.BundleID @@ -654,11 +677,13 @@ public class HomeAssistantAPI { }.asVoid() } - public var sharedEventDeviceInfo: [String: String] { [ - "sourceDevicePermanentID": AppConstants.PermanentID, - "sourceDeviceName": server.info.setting(for: .overrideDeviceName) ?? Current.device.deviceName(), - "sourceDeviceID": Current.settingsStore.deviceID, - ] } + public var sharedEventDeviceInfo: [String: String] { + [ + "sourceDevicePermanentID": AppConstants.PermanentID, + "sourceDeviceName": server.info.setting(for: .overrideDeviceName) ?? Current.device.deviceName(), + "sourceDeviceID": Current.settingsStore.deviceID, + ] + } public func legacyNotificationActionEvent( identifier: String, diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index cf76c635c1..19fbeba192 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -130,7 +130,28 @@ public class AppEnvironment { AreasService.shared } + /// APNs environment string for token reporting. "sandbox" in DEBUG builds, "production" otherwise. + /// TestFlight uses distribution signing and routes through the production APNs endpoint. + public var apnsEnvironment: String { + #if DEBUG + return "sandbox" + #else + return "production" + #endif + } + #if os(iOS) + #if canImport(ActivityKit) + /// Call `_ = Current.liveActivityRegistry` on the main thread at launch (before any + /// background thread can access it) to avoid a lazy-init race between concurrent callers. + public lazy var liveActivityRegistry: LiveActivityRegistryProtocol? = { + if #available(iOS 17.2, *) { + return LiveActivityRegistry() + } + return nil + }() + #endif + public var appDatabaseUpdater: AppDatabaseUpdaterProtocol = AppDatabaseUpdater.shared public var panelsUpdater: PanelsUpdaterProtocol = PanelsUpdater.shared @@ -248,7 +269,7 @@ public class AppEnvironment { public var backgroundTask: HomeAssistantBackgroundTaskRunner = ProcessInfoBackgroundTaskRunner() - // Use of 'appConfiguration' is preferred, but sometimes Beta builds are done as releases. + /// Use of 'appConfiguration' is preferred, but sometimes Beta builds are done as releases. public var isTestFlight = { #if DEBUG print("⚠️ isTestFlight returns TRUE while debugging") @@ -288,7 +309,7 @@ public class AppEnvironment { private let isFastlaneSnapshot = UserDefaults(suiteName: AppConstants.AppGroupID)!.bool(forKey: "FASTLANE_SNAPSHOT") - // This can be used to add debug statements. + /// This can be used to add debug statements. public var isDebug: Bool { #if DEBUG return true diff --git a/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift new file mode 100644 index 0000000000..a82abfc5fb --- /dev/null +++ b/Sources/Shared/LiveActivity/HALiveActivityAttributes.swift @@ -0,0 +1,109 @@ +#if canImport(ActivityKit) +import ActivityKit +import SwiftUI + +/// ActivityAttributes for Home Assistant Live Activities. +/// +/// Field names intentionally mirror the Android companion app's notification fields +/// so that automations can target both platforms with minimal differences. +/// +/// ⚠️ NEVER rename this struct or its fields post-ship. +/// The `attributes-type` string in APNs push-to-start payloads must exactly match +/// the Swift struct name (case-sensitive). Renaming breaks all in-flight activities. +@available(iOS 17.2, *) +public struct HALiveActivityAttributes: ActivityAttributes { + // MARK: - Static Attributes (set once at creation, cannot change) + + /// Unique identifier for this Live Activity. Maps to `tag` in the notification payload. + /// Same semantics as Android's `tag`: the same tag value updates in-place. + public let tag: String + + /// Display title for the activity. Maps to `title` in the notification payload. + public let title: String + + // MARK: - Dynamic State + + /// Codable state that can be updated via push or local update. + /// Field names map to Android companion app notification data fields. + public struct ContentState: Codable, Hashable { + /// Primary body text. Maps to `message` in the notification payload. + public var message: String + + /// Short text for Dynamic Island compact trailing view. + /// Maps to `critical_text` in the notification payload (≤ ~10 chars recommended). + public var criticalText: String? + + /// Current progress value (raw integer). Maps to `progress`. + public var progress: Int? + + /// Maximum progress value (raw integer). Maps to `progress_max`. + public var progressMax: Int? + + /// If true, show a countdown timer instead of static text. Maps to `chronometer`. + public var chronometer: Bool? + + /// Absolute end date for the countdown timer. + /// Computed from `when` + `when_relative` in the notification payload: + /// - `when_relative: true` → `Date().addingTimeInterval(Double(when))` + /// - `when_relative: false` → `Date(timeIntervalSince1970: Double(when))` + public var countdownEnd: Date? + + /// MDI icon slug for display. Maps to `notification_icon`. + public var icon: String? + + /// Hex color string for icon accent. Maps to `notification_icon_color`. + public var color: String? + + // MARK: - Computed helpers (not sent over wire) + + /// Progress as a fraction in [0, 1] for use in SwiftUI ProgressView. + public var progressFraction: Double? { + guard let p = progress, let m = progressMax, m > 0 else { return nil } + return Double(p) / Double(m) + } + + // MARK: - CodingKeys + + /// Explicit coding keys so that JSON field names match the Android notification fields. + enum CodingKeys: String, CodingKey { + case message + case criticalText = "critical_text" + case progress + case progressMax = "progress_max" + case chronometer + case countdownEnd = "countdown_end" + case icon + case color + } + + // MARK: - Init + + public init( + message: String, + criticalText: String? = nil, + progress: Int? = nil, + progressMax: Int? = nil, + chronometer: Bool? = nil, + countdownEnd: Date? = nil, + icon: String? = nil, + color: String? = nil + ) { + self.message = message + self.criticalText = criticalText + self.progress = progress + self.progressMax = progressMax + self.chronometer = chronometer + self.countdownEnd = countdownEnd + self.icon = icon + self.color = color + } + } + + // MARK: - Init + + public init(tag: String, title: String) { + self.tag = tag + self.title = title + } +} +#endif diff --git a/Sources/Shared/LiveActivity/LiveActivityRegistry.swift b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift new file mode 100644 index 0000000000..b4cf05f929 --- /dev/null +++ b/Sources/Shared/LiveActivity/LiveActivityRegistry.swift @@ -0,0 +1,377 @@ +#if canImport(ActivityKit) +import ActivityKit +import Foundation + +/// Stale date offset for all Live Activity content updates. +/// Activities are marked stale after 30 minutes if no further updates arrive. +private let kLiveActivityStaleInterval: TimeInterval = 30 * 60 + +public protocol LiveActivityRegistryProtocol: AnyObject { + @available(iOS 17.2, *) + func startOrUpdate(tag: String, title: String, state: HALiveActivityAttributes.ContentState) async throws + @available(iOS 17.2, *) + func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy) async + @available(iOS 17.2, *) + func reattach() async + @available(iOS 17.2, *) + func startObservingPushToStartToken() async +} + +/// Thread-safe registry for active `Activity` instances. +/// +/// Uses Swift's actor isolation to protect the `[String: Entry]` dictionary from +/// concurrent access by push handler queues, token observer tasks, and the main app. +/// +/// The reservation pattern prevents TOCTOU races where two pushes with the same `tag` +/// arrive back-to-back before the first `Activity.request(...)` completes. +@available(iOS 17.2, *) +public actor LiveActivityRegistry: LiveActivityRegistryProtocol { + // MARK: - Types + + struct Entry { + let activity: Activity + let observationTask: Task + } + + // MARK: - Webhook Constants (wire-format frozen — tested in LiveActivityContractTests) + + /// Webhook type for reporting a new per-activity push token to HA. + static let webhookTypeToken = "mobile_app_live_activity_token" + /// Keys in the token webhook request data dictionary. + static let tokenWebhookKeys: Set = ["activity_id", "push_token", "apns_environment"] + + /// Webhook type for reporting that a Live Activity was dismissed. + static let webhookTypeDismissed = "mobile_app_live_activity_dismissed" + /// Keys in the dismissed webhook request data dictionary. + static let dismissedWebhookKeys: Set = ["activity_id", "live_activity_tag", "reason"] + + // MARK: - State + + /// Tags currently in-flight (reserved but not yet confirmed or cancelled). + private var reserved: Set = [] + + /// Tags where `end()` arrived while still reserved — activity must be dismissed on confirm. + private var cancelledReservations: Set = [] + + /// Latest state received for a tag while it was still reserved (in-flight start). + /// Applied to the activity immediately after `confirmReservation` completes. + private var pendingState: [String: HALiveActivityAttributes.ContentState] = [:] + + /// Confirmed, running Live Activities keyed by tag. + private var entries: [String: Entry] = [:] + + // MARK: - Init + + public init() {} + + // MARK: - Reservation (internal — called within actor context) + + private func reserve(id: String) -> Bool { + guard entries[id] == nil, !reserved.contains(id) else { return false } + reserved.insert(id) + return true + } + + /// Confirm a reservation. If `end()` arrived while we were in-flight, immediately dismiss. + /// If a newer state arrived while we were in-flight, apply it after confirming. + private func confirmReservation(id: String, entry: Entry) async { + reserved.remove(id) + let pending = pendingState.removeValue(forKey: id) + if cancelledReservations.remove(id) != nil { + // end() was called before Activity.request() completed — dismiss immediately. + entry.observationTask.cancel() + await entry.activity.end(nil, dismissalPolicy: .immediate) + return + } + entries[id] = entry + if let latestState = pending { + // A second push arrived while Activity.request() was in-flight — apply the newer state now. + let content = ActivityContent( + state: latestState, + staleDate: computeStaleDate(for: latestState) + ) + await entry.activity.update(content) + } + } + + private func cancelReservation(id: String) { + reserved.remove(id) + cancelledReservations.remove(id) + pendingState.removeValue(forKey: id) + } + + private func remove(id: String) -> Entry? { + let entry = entries.removeValue(forKey: id) + entry?.observationTask.cancel() + return entry + } + + // MARK: - Public API + + /// Start a new Live Activity for `tag`, or update the existing one if already running. + public func startOrUpdate( + tag: String, + title: String, + state: HALiveActivityAttributes.ContentState + ) async throws { + // UPDATE path — activity already running with this tag + if let existing = entries[tag] { + let content = ActivityContent( + state: state, + staleDate: computeStaleDate(for: state) + ) + await existing.activity.update(content) + return + } + + // Also check system list in case we lost track after crash/relaunch + if let live = Activity.activities + .first(where: { $0.attributes.tag == tag }) { + let content = ActivityContent( + state: state, + staleDate: computeStaleDate(for: state) + ) + await live.update(content) + let observationTask = makeObservationTask(for: live) + entries[tag] = Entry(activity: live, observationTask: observationTask) + return + } + + // START path — guard against duplicates with reservation + guard reserve(id: tag) else { + if reserved.contains(tag) { + // Activity.request() is in-flight — save this state so confirmReservation applies it. + pendingState[tag] = state + Current.Log.info( + "LiveActivityRegistry: duplicate start for tag \(tag), will apply latest state on confirm" + ) + } + return + } + + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + cancelReservation(id: tag) + Current.Log.info("LiveActivityRegistry: activities disabled on this device, skipping start for tag \(tag)") + return + } + + let attributes = HALiveActivityAttributes(tag: tag, title: title) + let activity: Activity + + do { + let content = ActivityContent( + state: state, + staleDate: computeStaleDate(for: state), + relevanceScore: 0.5 + ) + activity = try Activity.request( + attributes: attributes, + content: content, + pushType: .token + ) + } catch { + cancelReservation(id: tag) + throw error + } + + // Immediately update with an AlertConfiguration to trigger the expanded Dynamic Island + // presentation. Activity.request() only shows the compact view (small pill around the + // camera cutout). The expanded "bloom" animation requires an update with an alert config. + let alertContent = ActivityContent( + state: state, + staleDate: computeStaleDate(for: state), + relevanceScore: 0.5 + ) + // iOS 26 SDK changed AlertConfiguration.sound from optional to non-optional. + // Use .default so the expanded Dynamic Island "bloom" has a subtle alert sound. + let alertConfig = AlertConfiguration( + title: LocalizedStringResource(stringLiteral: title), + body: LocalizedStringResource(stringLiteral: state.message), + sound: .default + ) + await activity.update(alertContent, alertConfiguration: alertConfig) + + let observationTask = makeObservationTask(for: activity) + await confirmReservation(id: tag, entry: Entry(activity: activity, observationTask: observationTask)) + Current.Log.verbose("LiveActivityRegistry: started activity for tag \(tag), id=\(activity.id)") + } + + /// End and dismiss the Live Activity for `tag`. + public func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy = .immediate) async { + if let existing = remove(id: tag) { + await existing.activity.end(nil, dismissalPolicy: dismissalPolicy) + Current.Log.verbose("LiveActivityRegistry: ended activity for tag \(tag)") + return + } + + // Tag is still being started (Activity.request in-flight) — mark it so confirmReservation + // dismisses the activity immediately once the request completes. + if reserved.contains(tag) { + cancelledReservations.insert(tag) + Current.Log + .verbose("LiveActivityRegistry: end() received for in-flight tag \(tag), will dismiss on confirm") + return + } + + // Fallback: check system list in case we lost track + if let live = Activity.activities + .first(where: { $0.attributes.tag == tag }) { + await live.end(nil, dismissalPolicy: dismissalPolicy) + } + } + + /// Re-attach observation tasks to any Live Activities that survived process termination. + /// Call this at app launch before any notification handlers are invoked. + public func reattach() async { + for activity in Activity.activities { + let tag = activity.attributes.tag + guard entries[tag] == nil else { continue } + let observationTask = makeObservationTask(for: activity) + entries[tag] = Entry(activity: activity, observationTask: observationTask) + Current.Log.verbose("LiveActivityRegistry: reattached activity for tag \(tag), id=\(activity.id)") + } + } + + /// Observe the push-to-start token stream for `HALiveActivityAttributes`. + /// + /// Push-to-start (iOS 17.2+) allows HA to start a Live Activity entirely via APNs + /// without the app being in the foreground. This is best-effort (~50% success from + /// terminated state) — the primary flow remains notification command → app starts activity. + /// + /// The token is stored in Keychain and reported to HA via registration update so the + /// relay server can use it to send push-to-start APNs payloads. + /// + /// Call this once at app launch; the stream is infinite and self-managing. + public func startObservingPushToStartToken() async { + for await tokenData in Activity.pushToStartTokenUpdates { + let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() + Current.Log.verbose("LiveActivityRegistry: new push-to-start token") + + // Store in Keychain — this token is higher-value than a per-activity token + // (it can start any new activity) so UserDefaults is intentionally avoided. + AppConstants.Keychain[LiveActivityRegistry.pushToStartTokenKeychainKey] = tokenHex + + // Report to all HA servers via registration update so the token is available + // in the HA device registry immediately. + reportPushToStartToken(tokenHex) + } + } + + // MARK: - Public Helpers + + /// The stored push-to-start token for inclusion in registration payloads. + /// Returns nil if the device hasn't received a token yet (pre-iOS 17.2 or not yet issued). + public static var storedPushToStartToken: String? { + AppConstants.Keychain[pushToStartTokenKeychainKey] + } + + static let pushToStartTokenKeychainKey = "live_activity_push_to_start_token" + + // MARK: - Private — Stale Date + + /// Compute the appropriate stale date for a Live Activity content update. + /// + /// When a countdown timer is active, set staleDate = countdownEnd + 2 s so that: + /// 1. The system marks the activity stale shortly after the timer reaches zero, + /// prompting HA to send a follow-up update. + /// 2. staleDate is never exactly equal to countdownEnd — that causes the system + /// to show a spinner overlay on the lock screen presentation. + /// + /// For non-timer activities, fall back to the standard 30-minute freshness window. + private func computeStaleDate(for state: HALiveActivityAttributes.ContentState) -> Date { + if state.chronometer == true, let end = state.countdownEnd { + // +2 s offset avoids staleDate == countdownEnd (system spinner bug). + // max(..., now + 2) guards against a countdownEnd that is already in the past. + return max(end.addingTimeInterval(2), Date().addingTimeInterval(2)) + } + return Date().addingTimeInterval(kLiveActivityStaleInterval) + } + + // MARK: - Private — Observation + + private func makeObservationTask(for activity: Activity) -> Task { + Task { + await withTaskGroup(of: Void.self) { group in + // Observe push token updates — report each new token to all HA servers + group.addTask { + for await tokenData in activity.pushTokenUpdates { + let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() + Current.Log.verbose( + "LiveActivityRegistry: new push token for tag \(activity.attributes.tag)" + ) + await self.reportPushToken(tokenHex, activityID: activity.id) + } + } + + // Observe activity lifecycle — clean up and notify HA when dismissed + group.addTask { + for await state in activity.activityStateUpdates { + switch state { + case .dismissed, .ended: + await self.reportActivityDismissed( + activityID: activity.id, + tag: activity.attributes.tag, + reason: state == .dismissed ? "user_dismissed" : "ended" + ) + _ = await self.remove(id: activity.attributes.tag) + return + case .active, .stale: + break + case .pending: + // Activity has been requested but not yet displayed — no action needed. + break + @unknown default: + break + } + } + } + } + } + } + + // MARK: - Private — Webhook Reporting + + /// Report a new activity push token to all connected HA servers. + /// The token is used by the relay server to send APNs updates directly to this activity. + private func reportPushToken(_ tokenHex: String, activityID: String) async { + let request = WebhookRequest( + type: Self.webhookTypeToken, + data: [ + "activity_id": activityID, + "push_token": tokenHex, + "apns_environment": Current.apnsEnvironment, + ] + ) + for server in Current.servers.all { + Current.webhooks.sendEphemeral(server: server, request: request).cauterize() + } + } + + /// Notify HA servers that the Live Activity was dismissed or ended externally. + /// This allows HA to stop sending APNs updates for this activity. + private func reportActivityDismissed(activityID: String, tag: String, reason: String) async { + let request = WebhookRequest( + type: Self.webhookTypeDismissed, + data: [ + "activity_id": activityID, + "live_activity_tag": tag, + "reason": reason, + ] + ) + for server in Current.servers.all { + Current.webhooks.sendEphemeral(server: server, request: request).cauterize() + } + } + + /// Report the push-to-start token to all HA servers via registration update. + /// HA stores this alongside the FCM push token in the device registry. + /// Fire-and-forget: errors are logged but do not block the token observation loop. + private func reportPushToStartToken(_ tokenHex: String) { + for api in Current.apis { + api.updateRegistration().catch { error in + Current.Log.error("LiveActivityRegistry: failed to report push-to-start token: \(error)") + } + } + } +} +#endif diff --git a/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift new file mode 100644 index 0000000000..f511ba5693 --- /dev/null +++ b/Sources/Shared/Notifications/NotificationCommands/HandlerLiveActivity.swift @@ -0,0 +1,185 @@ +#if canImport(ActivityKit) +import ActivityKit +import Foundation +import PromiseKit + +// MARK: - HandlerStartOrUpdateLiveActivity + +/// Handles `live_update: true` notifications by starting or updating a Live Activity. +/// +/// Triggered two ways: +/// 1. `homeassistant.command == "live_activity"` (message: live_activity in YAML) +/// 2. `homeassistant.live_update == true` (data.live_update: true in YAML) +/// +/// Notification payload fields mirror the Android companion app: +/// tag, title, message, critical_text, progress, progress_max, +/// chronometer, when, when_relative, notification_icon, notification_icon_color +@available(iOS 17.2, *) +struct HandlerStartOrUpdateLiveActivity: NotificationCommandHandler { + private enum ValidationError: Error { + case missingTag + case missingTitle + case invalidTag + } + + func handle(_ payload: [String: Any]) -> Promise { + // PushProvider (NEAppPushProvider) runs in a separate OS process — ActivityKit is + // unavailable there. The same notification will be re-delivered to the main app via + // UNUserNotificationCenter, where it will be handled correctly. + guard !Current.isAppExtension else { + Current.Log.verbose("HandlerStartOrUpdateLiveActivity: skipping in app extension, will handle in main app") + return .value(()) + } + + return Promise { seal in + Task { + do { + guard let tag = payload["tag"] as? String, !tag.isEmpty else { + throw ValidationError.missingTag + } + + guard Self.isValidTag(tag) else { + Current.Log + .error( + "HandlerStartOrUpdateLiveActivity: invalid tag '\(tag)' — must be [a-zA-Z0-9_-], max 64 chars" + ) + throw ValidationError.invalidTag + } + + guard let title = payload["title"] as? String, !title.isEmpty else { + throw ValidationError.missingTitle + } + + Self.showPrivacyDisclosureIfNeeded() + + let state = Self.contentState(from: payload) + + try await Current.liveActivityRegistry?.startOrUpdate( + tag: tag, + title: title, + state: state + ) + seal.fulfill(()) + } catch { + Current.Log.error("HandlerStartOrUpdateLiveActivity: \(error)") + // Fulfill rather than reject for known validation/auth errors so HA + // doesn't treat them as transient failures and retry indefinitely. + switch error { + case ValidationError.missingTag, ValidationError.missingTitle, ValidationError.invalidTag: + seal.fulfill(()) + default: + seal.reject(error) + } + } + } + } + } + + // MARK: - Privacy Disclosure + + /// Records that the user has started a Live Activity so that the Settings screen + /// can surface the privacy notice on their next visit. + /// The permanent disclosure lives in LiveActivitySettingsView's privacy section — + /// a local notification would silently fail if notification permission is not granted. + private static func showPrivacyDisclosureIfNeeded() { + guard !Current.settingsStore.hasSeenLiveActivityDisclosure else { return } + Current.settingsStore.hasSeenLiveActivityDisclosure = true + } + + // MARK: - Validation + + /// Validates that a Live Activity tag contains only safe characters. + /// + /// Tags are used as ActivityKit push token topic identifiers and as keys in + /// the activity registry dictionary. Restricting to `[a-zA-Z0-9_-]` (max 64 + /// characters) ensures they are safe for APNs payloads, UserDefaults keys, + /// and log output without escaping or truncation issues. + static func isValidTag(_ tag: String) -> Bool { + guard tag.count <= 64 else { return false } + let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") + return tag.unicodeScalars.allSatisfy { allowed.contains($0) } + } + + // MARK: - Payload Parsing + + static func contentState(from payload: [String: Any]) -> HALiveActivityAttributes.ContentState { + let message = payload["message"] as? String ?? "" + let criticalText = payload["critical_text"] as? String + // Use NSNumber coercion so both Int and Double JSON values (e.g. 50 vs 50.0) decode correctly. + let progress = (payload["progress"] as? NSNumber).map { Int(truncating: $0) } + let progressMax = (payload["progress_max"] as? NSNumber).map { Int(truncating: $0) } + let chronometer = payload["chronometer"] as? Bool + let icon = payload["notification_icon"] as? String + let color = payload["notification_icon_color"] as? String + + // `when` + `when_relative` → absolute countdown end date. + // Parsed as Double to preserve sub-second Unix timestamps sent by HA. + var countdownEnd: Date? + if let when = (payload["when"] as? NSNumber).map(\.doubleValue) { + let whenRelative = payload["when_relative"] as? Bool ?? false + if whenRelative { + countdownEnd = Date().addingTimeInterval(when) + } else { + countdownEnd = Date(timeIntervalSince1970: when) + } + } + + return HALiveActivityAttributes.ContentState( + message: message, + criticalText: criticalText, + progress: progress, + progressMax: progressMax, + chronometer: chronometer, + countdownEnd: countdownEnd, + icon: icon, + color: color + ) + } +} + +// MARK: - HandlerEndLiveActivity + +/// Handles explicit `end_live_activity` commands. +/// Note: the `clear_notification` + `tag` dismiss flow is handled in `HandlerClearNotification`. +@available(iOS 17.2, *) +struct HandlerEndLiveActivity: NotificationCommandHandler { + func handle(_ payload: [String: Any]) -> Promise { + guard !Current.isAppExtension else { + return .value(()) + } + + return Promise { seal in + Task { + guard let tag = payload["tag"] as? String, !tag.isEmpty, + HandlerStartOrUpdateLiveActivity.isValidTag(tag) else { + seal.fulfill(()) + return + } + + let policy = Self.dismissalPolicy(from: payload) + await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: policy) + seal.fulfill(()) + } + } + } + + private static func dismissalPolicy(from payload: [String: Any]) -> ActivityUIDismissalPolicy { + switch payload["dismissal_policy"] as? String { + case "default": + return .default + case let str where str?.hasPrefix("after:") == true: + if let timestampStr = str?.dropFirst(6), + let timestamp = Double(timestampStr) { + // Cap to 24 hours — iOS enforces its own maximum, but this prevents + // a far-future date from lingering in the dismissed activities list + // longer than intended if Apple ever relaxes the OS limit. + let maxDate = Date().addingTimeInterval(24 * 60 * 60) + return .after(min(Date(timeIntervalSince1970: timestamp), maxDate)) + } + return .immediate + default: + return .immediate + } + } +} +#endif diff --git a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift index 215ed06634..98d89d90bb 100644 --- a/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift +++ b/Sources/Shared/Notifications/NotificationCommands/NotificationsCommandManager.swift @@ -22,6 +22,12 @@ public class NotificationCommandManager { register(command: "clear_notification", handler: HandlerClearNotification()) #if os(iOS) register(command: "update_complications", handler: HandlerUpdateComplications()) + #if canImport(ActivityKit) + if #available(iOS 17.2, *) { + register(command: "live_activity", handler: HandlerStartOrUpdateLiveActivity()) + register(command: "end_live_activity", handler: HandlerEndLiveActivity()) + } + #endif #endif #if os(iOS) || os(macOS) @@ -36,8 +42,20 @@ public class NotificationCommandManager { } public func handle(_ payload: [AnyHashable: Any]) -> Promise { - guard let hadict = payload["homeassistant"] as? [String: Any], - let command = hadict["command"] as? String else { + guard let hadict = payload["homeassistant"] as? [String: Any] else { + return .init(error: CommandError.notCommand) + } + + // Support data.live_update: true — the same field Android uses for Live Updates. + // A single YAML automation can target both platforms with no platform-specific keys. + #if canImport(ActivityKit) + if #available(iOS 17.2, *), hadict["live_update"] as? Bool == true, + let handler = commands["live_activity"] { + return handler.handle(hadict) + } + #endif + + guard let command = hadict["command"] as? String else { return .init(error: CommandError.notCommand) } @@ -89,6 +107,23 @@ private struct HandlerClearNotification: NotificationCommandHandler { if !keys.isEmpty { UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: keys) } + + // Also end any Live Activity whose tag matches — same YAML works on both iOS and Android. + // Bridged into the returned Promise so the background fetch window stays open until + // the activity is actually dismissed (prevents the OS suspending mid-dismiss). + // ActivityKit is unavailable in the PushProvider extension, so guard accordingly. + #if os(iOS) && canImport(ActivityKit) + if #available(iOS 17.2, *), !Current.isAppExtension, let tag = payload["tag"] as? String { + return Promise { seal in + Task { + await Current.liveActivityRegistry?.end(tag: tag, dismissalPolicy: .immediate) + // https://stackoverflow.com/a/56657888/6324550 + DispatchQueue.main.async { seal.fulfill(()) } + } + } + } + #endif + // https://stackoverflow.com/a/56657888/6324550 return Promise { seal in DispatchQueue.main.async { diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 5f4e49948c..fee2e91aaa 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -1856,6 +1856,53 @@ public enum L10n { public static var disclaimer: String { return L10n.tr("Localizable", "legacy_actions.disclaimer") } } + public enum LiveActivity { + /// No active Live Activities + public static var emptyState: String { return L10n.tr("Localizable", "live_activity.empty_state") } + /// Real-time Home Assistant updates on your Lock Screen and Dynamic Island. + public static var subtitle: String { return L10n.tr("Localizable", "live_activity.subtitle") } + /// Live Activities + public static var title: String { return L10n.tr("Localizable", "live_activity.title") } + public enum EndAll { + /// End All Activities + public static var button: String { return L10n.tr("Localizable", "live_activity.end_all.button") } + public enum Confirm { + /// End All + public static var button: String { return L10n.tr("Localizable", "live_activity.end_all.confirm.button") } + /// End all Live Activities? + public static var title: String { return L10n.tr("Localizable", "live_activity.end_all.confirm.title") } + } + } + public enum FrequentUpdates { + /// Allows Home Assistant to update Live Activities up to once per second. Enable in Settings u203A %@ u203A Live Activities. + public static func footer(_ p1: Any) -> String { + return L10n.tr("Localizable", "live_activity.frequent_updates.footer", String(describing: p1)) + } + /// Frequent Updates + public static var title: String { return L10n.tr("Localizable", "live_activity.frequent_updates.title") } + } + public enum Privacy { + /// Live Activity content is visible on your Lock Screen and Dynamic Island without Face ID or Touch ID. Choose what you display carefully. + public static var message: String { return L10n.tr("Localizable", "live_activity.privacy.message") } + } + public enum Section { + /// Active Activities + public static var active: String { return L10n.tr("Localizable", "live_activity.section.active") } + /// Privacy + public static var privacy: String { return L10n.tr("Localizable", "live_activity.section.privacy") } + /// Status + public static var status: String { return L10n.tr("Localizable", "live_activity.section.status") } + } + public enum Status { + /// Enabled + public static var enabled: String { return L10n.tr("Localizable", "live_activity.status.enabled") } + /// Not available on iPad + public static var notSupported: String { return L10n.tr("Localizable", "live_activity.status.not_supported") } + /// Open Settings + public static var openSettings: String { return L10n.tr("Localizable", "live_activity.status.open_settings") } + } + } + public enum LocationChangeNotification { /// Location change public static var title: String { return L10n.tr("Localizable", "location_change_notification.title") } diff --git a/Sources/Shared/Settings/SettingsStore.swift b/Sources/Shared/Settings/SettingsStore.swift index f6b2a83083..1cc470b247 100644 --- a/Sources/Shared/Settings/SettingsStore.swift +++ b/Sources/Shared/Settings/SettingsStore.swift @@ -220,6 +220,17 @@ public class SettingsStore { } } + /// Whether the one-time Live Activity lock screen privacy disclosure has been shown. + /// Set to true after the first Live Activity is started; never reset. + public var hasSeenLiveActivityDisclosure: Bool { + get { + prefs.bool(forKey: "hasSeenLiveActivityDisclosure") + } + set { + prefs.set(newValue, forKey: "hasSeenLiveActivityDisclosure") + } + } + /// Local push becomes opt-in on 2025.6, users will have local push reset and need to re-enable it public var migratedOptInLocalPush: Bool { get { diff --git a/Sources/SharedPush/Sources/NotificationParserLegacy.swift b/Sources/SharedPush/Sources/NotificationParserLegacy.swift index 1989e5b624..58be8ff010 100644 --- a/Sources/SharedPush/Sources/NotificationParserLegacy.swift +++ b/Sources/SharedPush/Sources/NotificationParserLegacy.swift @@ -212,6 +212,30 @@ public struct LegacyNotificationParserImpl: LegacyNotificationParser { headers["apns-collapse-id"] = tag } + // Promote live_update fields from `data` into `homeassistant` so that + // NotificationCommandManager can route to HandlerStartOrUpdateLiveActivity. + // This handles the WebSocket (local push) delivery path where the parser + // produces a flat payload — unlike APNs which already has a `homeassistant` key. + if data["live_update"] as? Bool == true { + var homeassistant = payload["homeassistant"] as? [String: Any] ?? [:] + homeassistant["live_update"] = true + for key in [ + "tag", "critical_text", "progress", "progress_max", "chronometer", + "when", "when_relative", "notification_icon", "notification_icon_color", + ] { + if let value = data[key] { + homeassistant[key] = value + } + } + if let title = input["title"] as? String { + homeassistant["title"] = title + } + if let message = input["message"] as? String { + homeassistant["message"] = message + } + payload["homeassistant"] = homeassistant + } + if registrationInfo["os_version"]?.starts(with: "10.15") == true { payload.mutateInside("aps") { aps in if let sound = aps["sound"] as? String { diff --git a/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift new file mode 100644 index 0000000000..5f563cdee1 --- /dev/null +++ b/Tests/Shared/LiveActivity/HandlerLiveActivityTests.swift @@ -0,0 +1,330 @@ +#if canImport(ActivityKit) +import Foundation +import PromiseKit +@testable import Shared +import XCTest + +// MARK: - HandlerStartOrUpdateLiveActivity Tests + +@available(iOS 17.2, *) +final class HandlerStartOrUpdateLiveActivityTests: XCTestCase { + private var sut: HandlerStartOrUpdateLiveActivity! + private var mockRegistry: MockLiveActivityRegistry! + + override func setUp() { + super.setUp() + sut = HandlerStartOrUpdateLiveActivity() + mockRegistry = MockLiveActivityRegistry() + Current.liveActivityRegistry = mockRegistry + Current.isAppExtension = false + } + + override func tearDown() { + sut = nil + mockRegistry = nil + super.tearDown() + } + + // MARK: - isValidTag + + func testIsValidTag_alphanumericOnly_isValid() { + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag("abc123")) + } + + func testIsValidTag_withHyphen_isValid() { + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag("ha-tag")) + } + + func testIsValidTag_withUnderscore_isValid() { + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag("ha_tag")) + } + + func testIsValidTag_exactly64Chars_isValid() { + let tag = String(repeating: "a", count: 64) + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag(tag)) + } + + func testIsValidTag_65Chars_isInvalid() { + let tag = String(repeating: "a", count: 65) + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag(tag)) + } + + func testIsValidTag_withSpace_isInvalid() { + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag("ha tag")) + } + + func testIsValidTag_withDot_isInvalid() { + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag("ha.tag")) + } + + func testIsValidTag_withSlash_isInvalid() { + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag("ha/tag")) + } + + func testIsValidTag_withAtSign_isInvalid() { + XCTAssertFalse(HandlerStartOrUpdateLiveActivity.isValidTag("ha@tag")) + } + + func testIsValidTag_emptyString_isValid() { + // isValidTag uses allSatisfy which returns true vacuously for empty strings. + // Empty tags are rejected earlier in handle() via the !tag.isEmpty guard, + // so isValidTag is never called with an empty string in practice. + XCTAssertTrue(HandlerStartOrUpdateLiveActivity.isValidTag("")) + } + + // MARK: - contentState(from:) + + func testContentState_minimalPayload_usesDefaults() { + let state = HandlerStartOrUpdateLiveActivity.contentState(from: [:]) + XCTAssertEqual(state.message, "") + XCTAssertNil(state.criticalText) + XCTAssertNil(state.progress) + XCTAssertNil(state.progressMax) + XCTAssertNil(state.chronometer) + XCTAssertNil(state.countdownEnd) + XCTAssertNil(state.icon) + XCTAssertNil(state.color) + } + + func testContentState_fullPayload_mapsAllFields() { + let payload: [String: Any] = [ + "message": "Test message", + "critical_text": "CRITICAL", + "progress": 42, + "progress_max": 100, + "chronometer": true, + "notification_icon": "mdi:home", + "notification_icon_color": "#FF5733", + ] + let state = HandlerStartOrUpdateLiveActivity.contentState(from: payload) + XCTAssertEqual(state.message, "Test message") + XCTAssertEqual(state.criticalText, "CRITICAL") + XCTAssertEqual(state.progress, 42) + XCTAssertEqual(state.progressMax, 100) + XCTAssertEqual(state.chronometer, true) + XCTAssertEqual(state.icon, "mdi:home") + XCTAssertEqual(state.color, "#FF5733") + XCTAssertNil(state.countdownEnd) + } + + func testContentState_progressAsDouble_truncatesToInt() { + // JSON may send progress as 50.0 (Double) rather than 50 (Int) + let payload: [String: Any] = ["progress": NSNumber(value: 50.9)] + let state = HandlerStartOrUpdateLiveActivity.contentState(from: payload) + XCTAssertEqual(state.progress, 50) + } + + func testContentState_whenAbsolute_usesEpochTimestamp() { + let timestamp: Double = 1_700_000_000 + let payload: [String: Any] = ["when": NSNumber(value: timestamp), "when_relative": false] + let state = HandlerStartOrUpdateLiveActivity.contentState(from: payload) + XCTAssertEqual(state.countdownEnd?.timeIntervalSince1970 ?? 0, timestamp, accuracy: 0.001) + } + + func testContentState_whenRelative_addsIntervalToNow() { + let interval: Double = 300 // 5 minutes from now + let payload: [String: Any] = ["when": NSNumber(value: interval), "when_relative": true] + let before = Date() + let state = HandlerStartOrUpdateLiveActivity.contentState(from: payload) + let after = Date() + + guard let countdownEnd = state.countdownEnd else { + return XCTFail("countdownEnd should not be nil") + } + XCTAssertGreaterThanOrEqual(countdownEnd.timeIntervalSince(before), interval - 0.1) + XCTAssertLessThanOrEqual(countdownEnd.timeIntervalSince(after), interval + 0.1) + } + + func testContentState_whenMissing_countdownEndIsNil() { + let state = HandlerStartOrUpdateLiveActivity.contentState(from: ["when_relative": true]) + XCTAssertNil(state.countdownEnd) + } + + // MARK: - handle(_:) — app extension guard + + func testHandle_inAppExtension_skipsRegistryAndFulfills() { + Current.isAppExtension = true + let payload: [String: Any] = ["tag": "test-tag", "title": "Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + // MARK: - handle(_:) — validation failures fulfill (no rejection) + + func testHandle_missingTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["title": "Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + func testHandle_emptyTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "", "title": "Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + func testHandle_invalidTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "invalid tag with spaces", "title": "Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + func testHandle_missingTitle_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "valid-tag"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + func testHandle_emptyTitle_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "valid-tag", "title": ""] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + // MARK: - handle(_:) — successful path + + func testHandle_validPayload_callsRegistryStartOrUpdate() throws { + let payload: [String: Any] = ["tag": "my-activity", "title": "My Title", "message": "Hello"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.startOrUpdateCalls.count, 1) + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].tag, "my-activity") + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].title, "My Title") + } + + func testHandle_registryThrows_rejectsPromise() { + struct TestError: Error {} + mockRegistry.startOrUpdateError = TestError() + let payload: [String: Any] = ["tag": "my-activity", "title": "My Title"] + XCTAssertThrowsError(try hang(sut.handle(payload))) + } + + // MARK: - Privacy disclosure + + func testHandle_firstCall_setsDisclosureFlag() throws { + Current.settingsStore.hasSeenLiveActivityDisclosure = false + let payload: [String: Any] = ["tag": "priv-tag", "title": "Privacy Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(Current.settingsStore.hasSeenLiveActivityDisclosure) + } + + func testHandle_disclosureAlreadySeen_doesNotChange() throws { + Current.settingsStore.hasSeenLiveActivityDisclosure = true + let payload: [String: Any] = ["tag": "priv-tag", "title": "Privacy Test"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + // Still true — unchanged + XCTAssertTrue(Current.settingsStore.hasSeenLiveActivityDisclosure) + } +} + +// MARK: - HandlerEndLiveActivity Tests + +@available(iOS 17.2, *) +final class HandlerEndLiveActivityTests: XCTestCase { + private var sut: HandlerEndLiveActivity! + private var mockRegistry: MockLiveActivityRegistry! + + override func setUp() { + super.setUp() + sut = HandlerEndLiveActivity() + mockRegistry = MockLiveActivityRegistry() + Current.liveActivityRegistry = mockRegistry + Current.isAppExtension = false + } + + override func tearDown() { + sut = nil + mockRegistry = nil + super.tearDown() + } + + // MARK: - App extension guard + + func testHandle_inAppExtension_skipsRegistryAndFulfills() { + Current.isAppExtension = true + let payload: [String: Any] = ["tag": "test-tag"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + // MARK: - Tag validation + + func testHandle_missingTag_fulfillsWithoutCallingRegistry() { + XCTAssertNoThrow(try hang(sut.handle([:]))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + func testHandle_emptyTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": ""] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + func testHandle_invalidTag_fulfillsWithoutCallingRegistry() { + let payload: [String: Any] = ["tag": "bad tag!"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + // MARK: - Dismissal policy + + func testHandle_noDismissalPolicy_usesImmediate() { + let payload: [String: Any] = ["tag": "end-tag"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertEqual(mockRegistry.endCalls[0].tag, "end-tag") + XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) + } + + func testHandle_defaultDismissalPolicy_usesDefault() { + let payload: [String: Any] = ["tag": "end-tag", "dismissal_policy": "default"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsDefault) + } + + func testHandle_afterDismissalPolicy_usesAfterPolicy() { + let future = Date().addingTimeInterval(60) + let payload: [String: Any] = [ + "tag": "end-tag", + "dismissal_policy": "after:\(future.timeIntervalSince1970)", + ] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + // Verify an .after policy was chosen (not .immediate or .default) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsAfter) + // Verify the stored policy matches the expected date (ActivityUIDismissalPolicy is Equatable) + let expectedDate = Date(timeIntervalSince1970: future.timeIntervalSince1970) + XCTAssertEqual(mockRegistry.endCalls[0].policy, .after(expectedDate)) + } + + func testHandle_afterDismissalPolicy_capsAt24Hours() { + // A timestamp 48 hours in the future should be capped to ≤24 hours + let farFuture = Date().addingTimeInterval(48 * 60 * 60) + let payload: [String: Any] = [ + "tag": "end-tag", + "dismissal_policy": "after:\(farFuture.timeIntervalSince1970)", + ] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + let call = mockRegistry.endCalls[0] + // The policy should be .after (not .immediate), confirming it wasn't discarded + XCTAssertTrue(call.policyIsAfter) + // The stored date must not equal the uncapped far-future date + XCTAssertNotEqual(call.policy, .after(Date(timeIntervalSince1970: farFuture.timeIntervalSince1970))) + } + + func testHandle_afterDismissalPolicyWithInvalidTimestamp_usesImmediate() { + let payload: [String: Any] = ["tag": "end-tag", "dismissal_policy": "after:not-a-number"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) + } + + func testHandle_unknownDismissalPolicy_usesImmediate() { + let payload: [String: Any] = ["tag": "end-tag", "dismissal_policy": "unknown"] + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) + } +} +#endif diff --git a/Tests/Shared/LiveActivity/LiveActivityContractTests.swift b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift new file mode 100644 index 0000000000..1f4e3a631a --- /dev/null +++ b/Tests/Shared/LiveActivity/LiveActivityContractTests.swift @@ -0,0 +1,166 @@ +#if canImport(ActivityKit) +import Foundation +@testable import Shared +import XCTest + +/// Contract tests that validate wire-format frozen values won't change. +/// +/// These values appear in APNs payloads, webhook requests, and notification routing. +/// Changing them would break communication with the HA server, relay server, or APNs. +/// If a test fails, it means a wire-format contract was broken — do not simply update +/// the expected value without coordinating with all server-side consumers. +@available(iOS 17.2, *) +final class LiveActivityContractTests: XCTestCase { + // MARK: - HALiveActivityAttributes (wire-format frozen struct) + + /// The struct name appears as `attributes-type` in APNs push-to-start payloads. + /// Renaming it silently breaks all remote starts. + func testAttributesTypeName_isFrozen() { + let typeName = String(describing: HALiveActivityAttributes.self) + XCTAssertEqual(typeName, "HALiveActivityAttributes") + } + + /// CodingKeys define the JSON field names in APNs content-state payloads. + /// Adding new optional fields is safe; renaming or removing breaks in-flight activities. + func testContentState_codingKeys_areFrozen() { + let state = HALiveActivityAttributes.ContentState( + message: "test", + criticalText: "ct", + progress: 1, + progressMax: 2, + chronometer: true, + countdownEnd: Date(timeIntervalSince1970: 0), + icon: "mdi:test", + color: "#FF0000" + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let data = try! encoder.encode(state) + let dict = try! JSONSerialization.jsonObject(with: data) as! [String: Any] + + // These keys must match the Android notification field names exactly. + let expectedKeys: Set = [ + "message", + "critical_text", + "progress", + "progress_max", + "chronometer", + "countdown_end", + "icon", + "color", + ] + XCTAssertEqual(Set(dict.keys), expectedKeys) + } + + /// ContentState must round-trip through JSON without data loss. + func testContentState_roundTrip_preservesAllFields() { + let original = HALiveActivityAttributes.ContentState( + message: "Cycle in progress", + criticalText: "45 min", + progress: 2700, + progressMax: 3600, + chronometer: true, + countdownEnd: Date(timeIntervalSince1970: 1_700_000_000), + icon: "mdi:washing-machine", + color: "#2196F3" + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + + let data = try! encoder.encode(original) + let decoded = try! decoder.decode(HALiveActivityAttributes.ContentState.self, from: data) + + XCTAssertEqual(decoded, original) + } + + // MARK: - LiveActivityRegistry (webhook contracts) + + /// The Keychain key for the push-to-start token. Changing it would lose stored tokens. + func testPushToStartTokenKeychainKey_isFrozen() { + XCTAssertEqual( + LiveActivityRegistry.pushToStartTokenKeychainKey, + "live_activity_push_to_start_token" + ) + } + + /// Webhook type string for reporting a new per-activity push token. + /// Must match the HA core webhook handler name. + func testWebhookTypeToken_isFrozen() { + XCTAssertEqual( + LiveActivityRegistry.webhookTypeToken, + "mobile_app_live_activity_token" + ) + } + + /// Keys in the token webhook request data dictionary. + /// Must match what HA core's update_live_activity_token handler expects. + func testTokenWebhookKeys_areFrozen() { + XCTAssertEqual( + LiveActivityRegistry.tokenWebhookKeys, + ["activity_id", "push_token", "apns_environment"] + ) + } + + /// Webhook type string for reporting a dismissed activity. + /// Must match the HA core webhook handler name. + func testWebhookTypeDismissed_isFrozen() { + XCTAssertEqual( + LiveActivityRegistry.webhookTypeDismissed, + "mobile_app_live_activity_dismissed" + ) + } + + /// Keys in the dismissed webhook request data dictionary. + /// Must match what HA core's live_activity_dismissed handler expects. + func testDismissedWebhookKeys_areFrozen() { + XCTAssertEqual( + LiveActivityRegistry.dismissedWebhookKeys, + ["activity_id", "live_activity_tag", "reason"] + ) + } + + // MARK: - NotificationsCommandManager (command strings) + + /// The command strings that route to Live Activity handlers. + /// Changing these breaks the HA → app notification contract. + func testLiveActivityCommandStrings_areFrozen() { + let manager = NotificationCommandManager() + + // "live_activity" command must route successfully (not throw unknownCommand) + let liveActivityPayload: [AnyHashable: Any] = [ + "homeassistant": [ + "command": "live_activity", + "tag": "test", + "title": "Test", + "message": "Hello", + ] as [String: Any], + ] + XCTAssertNoThrow(try hang(manager.handle(liveActivityPayload))) + + // "end_live_activity" command must route successfully + let endPayload: [AnyHashable: Any] = [ + "homeassistant": [ + "command": "end_live_activity", + "tag": "test", + ] as [String: Any], + ] + XCTAssertNoThrow(try hang(manager.handle(endPayload))) + } + + /// The `live_update: true` data flag must be recognized (same field as Android Live Updates). + func testLiveUpdateDataFlag_isRecognized() { + let manager = NotificationCommandManager() + let payload: [AnyHashable: Any] = [ + "homeassistant": [ + "live_update": true, + "tag": "test", + "title": "Test", + "message": "Hello", + ] as [String: Any], + ] + XCTAssertNoThrow(try hang(manager.handle(payload))) + } +} +#endif diff --git a/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift b/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift new file mode 100644 index 0000000000..91318d4f72 --- /dev/null +++ b/Tests/Shared/LiveActivity/MockLiveActivityRegistry.swift @@ -0,0 +1,67 @@ +#if canImport(ActivityKit) +import ActivityKit +import Foundation +@testable import Shared + +/// Test double for `LiveActivityRegistryProtocol`. +/// Records all calls so tests can assert on what was invoked. +@available(iOS 17.2, *) +final class MockLiveActivityRegistry: LiveActivityRegistryProtocol { + // MARK: - Recorded Calls + + struct StartOrUpdateCall: Equatable { + let tag: String + let title: String + } + + struct EndCall { + let tag: String + let policy: ActivityUIDismissalPolicy + } + + private(set) var startOrUpdateCalls: [StartOrUpdateCall] = [] + private(set) var endCalls: [EndCall] = [] + private(set) var reattachCallCount = 0 + + // MARK: - Configurable Errors + + /// Set to make the next `startOrUpdate` throw. + var startOrUpdateError: Error? + + // MARK: - LiveActivityRegistryProtocol + + func startOrUpdate( + tag: String, + title: String, + state: HALiveActivityAttributes.ContentState + ) async throws { + if let error = startOrUpdateError { + startOrUpdateError = nil + throw error + } + startOrUpdateCalls.append(StartOrUpdateCall(tag: tag, title: title)) + } + + func end(tag: String, dismissalPolicy: ActivityUIDismissalPolicy) async { + endCalls.append(EndCall(tag: tag, policy: dismissalPolicy)) + } + + func reattach() async { + reattachCallCount += 1 + } + + func startObservingPushToStartToken() async { + // No-op in tests — token observation requires a real device/simulator push environment. + } +} + +// MARK: - EndCall helpers + +@available(iOS 17.2, *) +extension MockLiveActivityRegistry.EndCall { + var policyIsImmediate: Bool { policy == .immediate } + var policyIsDefault: Bool { policy == .default } + /// True when the policy is `.after(date)` for any date. + var policyIsAfter: Bool { !policyIsImmediate && !policyIsDefault } +} +#endif diff --git a/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift new file mode 100644 index 0000000000..8d5be00ab1 --- /dev/null +++ b/Tests/Shared/LiveActivity/NotificationsCommandManagerLiveActivityTests.swift @@ -0,0 +1,144 @@ +#if canImport(ActivityKit) +import Foundation +import PromiseKit +@testable import Shared +import XCTest + +/// Tests for the two live-activity routing paths in `NotificationCommandManager`: +/// 1. `homeassistant.command == "live_activity"` — explicit command key +/// 2. `homeassistant.live_update == true` — data flag (Android-compat pattern) +/// 3. `homeassistant.command == "end_live_activity"` — end command +/// 4. `homeassistant.command == "clear_notification"` with a `tag` — dismisses live activity +@available(iOS 17.2, *) +final class NotificationsCommandManagerLiveActivityTests: XCTestCase { + private var sut: NotificationCommandManager! + private var mockRegistry: MockLiveActivityRegistry! + + override func setUp() { + super.setUp() + mockRegistry = MockLiveActivityRegistry() + Current.liveActivityRegistry = mockRegistry + Current.isAppExtension = false + sut = NotificationCommandManager() + } + + override func tearDown() { + sut = nil + mockRegistry = nil + super.tearDown() + } + + // MARK: - Helpers + + /// Wraps a `homeassistant` sub-dictionary in the outer notification payload structure. + private func makePayload(_ hadict: [String: Any]) -> [AnyHashable: Any] { + ["homeassistant": hadict] + } + + // MARK: - live_activity command routing + + func testHandle_liveActivityCommand_callsStartOrUpdate() { + let payload = makePayload([ + "command": "live_activity", + "tag": "cmd-tag", + "title": "Command Title", + "message": "Hello", + ]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.startOrUpdateCalls.count, 1) + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].tag, "cmd-tag") + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].title, "Command Title") + } + + // MARK: - live_update: true data flag routing (Android-compat) + + func testHandle_liveActivityFlag_callsStartOrUpdate() { + let payload = makePayload([ + "live_update": true, + "tag": "flag-tag", + "title": "Flag Title", + "message": "World", + ]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.startOrUpdateCalls.count, 1) + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].tag, "flag-tag") + XCTAssertEqual(mockRegistry.startOrUpdateCalls[0].title, "Flag Title") + } + + func testHandle_liveActivityFlagFalse_doesNotRouteToLiveActivity() { + // live_update: false should fall through to standard command routing + let payload = makePayload([ + "live_update": false, + "tag": "no-tag", + "title": "Should Not Route", + ]) + // No "command" key → returns notCommand error; registry is never called + XCTAssertThrowsError(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.startOrUpdateCalls.isEmpty) + } + + // MARK: - end_live_activity command + + func testHandle_endLiveActivityCommand_callsRegistryEnd() { + let payload = makePayload([ + "command": "end_live_activity", + "tag": "end-me", + ]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertEqual(mockRegistry.endCalls[0].tag, "end-me") + XCTAssertTrue(mockRegistry.endCalls[0].policyIsImmediate) + } + + func testHandle_endLiveActivityCommand_withDefaultPolicy_callsRegistryEndWithDefaultPolicy() { + let payload = makePayload([ + "command": "end_live_activity", + "tag": "end-me", + "dismissal_policy": "default", + ]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertEqual(mockRegistry.endCalls.count, 1) + XCTAssertTrue(mockRegistry.endCalls[0].policyIsDefault) + } + + // MARK: - clear_notification also ends live activity + + // NOTE: testHandle_clearNotificationWithTag_callsRegistryEnd is intentionally omitted. + // HandlerClearNotification calls UNUserNotificationCenter.current().removeDeliveredNotifications + // synchronously before reaching the live activity dismissal path. That API requires a real + // app bundle and throws NSInternalInconsistencyException in the XCTest host process. + // The clear_notification → live activity dismissal path is covered by code review and + // integration testing instead. + + func testHandle_clearNotificationWithoutTag_doesNotCallRegistryEnd() { + // No "tag" key → registry.end() must not be called. + // Intentionally omit "collapseId" too — including any key would trigger + // UNUserNotificationCenter which requires a real app bundle and crashes in tests. + let payload = makePayload(["command": "clear_notification"]) + XCTAssertNoThrow(try hang(sut.handle(payload))) + XCTAssertTrue(mockRegistry.endCalls.isEmpty) + } + + // MARK: - Missing homeassistant dict + + func testHandle_noHomeAssistantKey_throwsNotCommand() { + let payload: [AnyHashable: Any] = ["other": "value"] + XCTAssertThrowsError(try hang(sut.handle(payload))) { error in + guard case NotificationCommandManager.CommandError.notCommand = error else { + return XCTFail("Expected .notCommand, got \(error)") + } + } + } + + // MARK: - Unknown command + + func testHandle_unknownCommand_throwsUnknownCommand() { + let payload = makePayload(["command": "unknown_command_xyz"]) + XCTAssertThrowsError(try hang(sut.handle(payload))) { error in + guard case NotificationCommandManager.CommandError.unknownCommand = error else { + return XCTFail("Expected .unknownCommand, got \(error)") + } + } + } +} +#endif