diff --git a/Configuration/Entitlements/activate_special_entitlements.sh b/Configuration/Entitlements/activate_special_entitlements.sh index 330e5bf31..039f62af1 100755 --- a/Configuration/Entitlements/activate_special_entitlements.sh +++ b/Configuration/Entitlements/activate_special_entitlements.sh @@ -29,6 +29,17 @@ if [[ $TARGET_NAME = "App" ]]; then fi fi +if [[ $TARGET_NAME = "App" ]]; then + if [[ $CI && $CONFIGURATION != "Release" ]]; then + echo "warning: com.apple.developer.carplay-driving-task disabled for CI" + elif [[ ${ENABLE_CARPLAY} -eq 1 ]]; then + /usr/libexec/PlistBuddy -c "add com.apple.developer.carplay-driving-task bool true" "$ENTITLEMENTS_FILE" + else + echo "warning: com.apple.developer.carplay-driving-task entitlement disabled" + fi +fi + + if [[ $TARGET_NAME = "App" ]]; then if [[ $CI && $CONFIGURATION != "Release" ]]; then echo "warning: Device name disabled for CI" diff --git a/Configuration/HomeAssistant.xcconfig b/Configuration/HomeAssistant.xcconfig index 598e33377..c323133ee 100644 --- a/Configuration/HomeAssistant.xcconfig +++ b/Configuration/HomeAssistant.xcconfig @@ -7,6 +7,7 @@ ENABLE_CRITICAL_ALERTS_QMQYCKL255 = 1 ENABLE_PUSH_PROVIDER_QMQYCKL255 = 1 ENABLE_DEVICE_NAME_QMQYCKL255 = 1 ENABLE_THREAD_NETWORK_CREDENTIALS_QMQYCKL255 = 1 +ENABLE_CARPLAY_QMQYCKL255 = 1 // cascades down PRODUCT_BUNDLE_IDENTIFIER = ${BUNDLE_ID_PREFIX}.HomeAssistant${BUNDLE_ID_SUFFIX}${PROVISIONING_SUFFIX} @@ -30,6 +31,7 @@ ENABLE_CRITICAL_ALERTS[sdk=iphoneos*] = $(ENABLE_CRITICAL_ALERTS_$(DEVELOPMENT_T ENABLE_PUSH_PROVIDER[sdk=iphoneos*] = $(ENABLE_PUSH_PROVIDER_$(DEVELOPMENT_TEAM)) ENABLE_DEVICE_NAME[sdk=iphoneos*] = $(ENABLE_DEVICE_NAME_$(DEVELOPMENT_TEAM)) ENABLE_THREAD_NETWORK_CREDENTIALS[sdk=iphoneos*] = $(ENABLE_THREAD_NETWORK_CREDENTIALS_$(DEVELOPMENT_TEAM)) +ENABLE_CARPLAY[sdk=iphoneos*] = $(ENABLE_CARPLAY_$(DEVELOPMENT_TEAM)) // We mutate the entitlements at build time to support other development teams CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index fc45e3657..1fd621453 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -511,9 +511,20 @@ 42CE8FA82B45D1E900C707F9 /* CoreStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */; }; 42CE8FA92B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; 42CE8FAA2B45D1E900C707F9 /* FrontendStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */; }; + 42CE8FB02B46C3D900C707F9 /* CoreStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAE2B46C3D600C707F9 /* CoreStrings+Values.swift */; }; + 42CE8FB12B46C3DA00C707F9 /* CoreStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAE2B46C3D600C707F9 /* CoreStrings+Values.swift */; }; + 42CE8FB22B46C46E00C707F9 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAC2B46C12C00C707F9 /* Domain.swift */; }; + 42CE8FB32B46C46F00C707F9 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FAC2B46C12C00C707F9 /* Domain.swift */; }; + 42CE8FB62B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */; }; + 42CE8FB72B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */; }; + 42CE8FB92B46D67A00C707F9 /* HAEntity+CarPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66629BA003B00B19FBE /* HAEntity+CarPlay.swift */; }; + 42CE8FBA2B46D67A00C707F9 /* HAEntity+CarPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66629BA003B00B19FBE /* HAEntity+CarPlay.swift */; }; + 42CE8FBB2B46DB6200C707F9 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65B15042273188300635D5C /* Assets.swift */; }; 42DD84132B14ACAB00936F16 /* Color+ColorAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */; }; 42DD84162B14D7AC00936F16 /* WebViewExternalBusMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84142B14D68C00936F16 /* WebViewExternalBusMessage.swift */; }; 42DD84192B14D83B00936F16 /* WebViewExternalBusMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DD84182B14D83B00936F16 /* WebViewExternalBusMessageTests.swift */; }; + 42F1DA582B46FDD8002729BC /* HATypedRequest+CarPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1DA572B46FDD8002729BC /* HATypedRequest+CarPlay.swift */; }; + 42F1DA592B46FDD8002729BC /* HATypedRequest+CarPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F1DA572B46FDD8002729BC /* HATypedRequest+CarPlay.swift */; }; 42F5CAB92B10AD9800409816 /* ThreadCredentialsSharingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F5CAB82B10AD9800409816 /* ThreadCredentialsSharingViewModelTests.swift */; }; 42F5CABC2B10AE1A00409816 /* ServerFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42F5CABB2B10AE1A00409816 /* ServerFixture.swift */; }; 42F5CAE52B10CDC600409816 /* HACornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CA28AD2B101D4D0093B31A /* HACornerRadius.swift */; }; @@ -851,6 +862,9 @@ D0FF79D220D87D200034574D /* ClientEventTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FF79D120D87D200034574D /* ClientEventTableViewController.swift */; }; D0FF79D520D87DB10034574D /* ClientEvents.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D0FF79D420D87DB10034574D /* ClientEvents.storyboard */; }; 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 */; }; + FD3BC66C29BA00D600B19FBE /* EntitiesListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* EntitiesListTemplate.swift */; }; + FD3BC66E29BA010A00B19FBE /* DomainsListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */; }; FD5FEB304713F1E6BFE498DC /* Pods_iOS_Extensions_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE950A9D74B3E7FF5665CB38 /* Pods_iOS_Extensions_NotificationService.framework */; }; /* End PBXBuildFile section */ @@ -1610,6 +1624,9 @@ 42CA28BA2B1028330093B31A /* SimulatorThreadClientService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimulatorThreadClientService.swift; sourceTree = ""; }; 42CE8FA52B45D1E900C707F9 /* CoreStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CoreStrings.swift; sourceTree = ""; }; 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrontendStrings.swift; sourceTree = ""; }; + 42CE8FAC2B46C12C00C707F9 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; + 42CE8FAE2B46C3D600C707F9 /* CoreStrings+Values.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CoreStrings+Values.swift"; sourceTree = ""; }; + 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FrontendStrings+Values.swift"; sourceTree = ""; }; 42DD84122B14ACAB00936F16 /* Color+ColorAsset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+ColorAsset.swift"; sourceTree = ""; }; 42DD84142B14D68C00936F16 /* WebViewExternalBusMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewExternalBusMessage.swift; sourceTree = ""; }; 42DD84182B14D83B00936F16 /* WebViewExternalBusMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewExternalBusMessageTests.swift; sourceTree = ""; }; @@ -1621,6 +1638,7 @@ 42DD84372B15DC3F00936F16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Frontend.strings; sourceTree = ""; }; 42DD84382B15DC3F00936F16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/InfoPlist.strings; sourceTree = ""; }; 42DD84392B15DC3F00936F16 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = ""; }; + 42F1DA572B46FDD8002729BC /* HATypedRequest+CarPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HATypedRequest+CarPlay.swift"; sourceTree = ""; }; 42F5CAB82B10AD9800409816 /* ThreadCredentialsSharingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadCredentialsSharingViewModelTests.swift; sourceTree = ""; }; 42F5CABB2B10AE1A00409816 /* ServerFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFixture.swift; sourceTree = ""; }; 42F5CADF2B10CD2D00409816 /* ThreadCredentialsSharing+build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadCredentialsSharing+build.swift"; sourceTree = ""; }; @@ -2045,6 +2063,10 @@ F3A0FB3BD04C582E655168D0 /* Pods-Tests-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.release.xcconfig"; sourceTree = ""; }; F3E55AA06795782F04D0B261 /* 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 = ""; }; F534C18A6FD4884F258341C9 /* Pods-iOS-Shared-iOS.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.beta.xcconfig"; sourceTree = ""; }; + FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; + FD3BC66629BA003B00B19FBE /* HAEntity+CarPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HAEntity+CarPlay.swift"; sourceTree = ""; }; + FD3BC66B29BA00D600B19FBE /* EntitiesListTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitiesListTemplate.swift; sourceTree = ""; }; + FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsListTemplate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2243,6 +2265,7 @@ 1115018C2528411200DCFA94 /* Sources */ = { isa = PBXGroup; children = ( + FD3BC66429BA000A00B19FBE /* Vehicle */, B657A8E81CA646EB00121384 /* App */, 111501A72528412C00DCFA94 /* Extensions */, 11DE9D8425B6103C0081C0ED /* Launcher */, @@ -2847,6 +2870,7 @@ 11EFCDDB24F6065F00314D85 /* AboutSceneDelegate.swift */, 11EFCDDF24F60E5900314D85 /* BasicSceneDelegate.swift */, 118261F424F8C7C1000795C6 /* SceneManager.swift */, + FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */, ); path = Scenes; sourceTree = ""; @@ -3018,6 +3042,14 @@ path = Mocks; sourceTree = ""; }; + 42CE8FAB2B46C11E00C707F9 /* Domain */ = { + isa = PBXGroup; + children = ( + 42CE8FAC2B46C12C00C707F9 /* Domain.swift */, + ); + path = Domain; + sourceTree = ""; + }; 42DD84172B14D83400936F16 /* Tests */ = { isa = PBXGroup; children = ( @@ -3026,6 +3058,14 @@ path = Tests; sourceTree = ""; }; + 42F1DA562B46FDC5002729BC /* CarPlay */ = { + isa = PBXGroup; + children = ( + 42F1DA572B46FDD8002729BC /* HATypedRequest+CarPlay.swift */, + ); + path = CarPlay; + sourceTree = ""; + }; 42F5CAB72B10AD8C00409816 /* Tests */ = { isa = PBXGroup; children = ( @@ -3625,6 +3665,8 @@ isa = PBXGroup; children = ( 426740A42B17348700C1DD73 /* Assets */, + 42F1DA562B46FDC5002729BC /* CarPlay */, + 42CE8FAB2B46C11E00C707F9 /* Domain */, 42CA28AC2B101D320093B31A /* DesignSystem */, 11B38EE0275C545C00205C7B /* Intents */, D014EEAA212928EC008EA6F5 /* API */, @@ -3694,6 +3736,7 @@ D0A6367320DBE91300E5C49B /* Extensions */ = { isa = PBXGroup; children = ( + FD3BC66629BA003B00B19FBE /* HAEntity+CarPlay.swift */, B6DF8BC0221C890600370A59 /* UIImageView+UIActivityIndicator.swift */, B6B6B14B215B1E86003DE2DD /* CLKComplication+Strings.swift */, 114E9B4D24E89B1300B43EED /* INImage+MaterialDesignIcons.swift */, @@ -3803,6 +3846,8 @@ 42CE8FA62B45D1E900C707F9 /* FrontendStrings.swift */, 11EE9B4524C4E01500404AF8 /* SharedPlist.swift */, D0EEF31F214DE3B300D1D360 /* Strings.swift */, + 42CE8FAE2B46C3D600C707F9 /* CoreStrings+Values.swift */, + 42CE8FB42B46CAFD00C707F9 /* FrontendStrings+Values.swift */, ); path = Swiftgen; sourceTree = ""; @@ -3854,6 +3899,23 @@ path = Common; sourceTree = ""; }; + FD3BC66429BA000A00B19FBE /* Vehicle */ = { + isa = PBXGroup; + children = ( + FD3BC66A29BA00B100B19FBE /* Templates */, + ); + path = Vehicle; + sourceTree = ""; + }; + FD3BC66A29BA00B100B19FBE /* Templates */ = { + isa = PBXGroup; + children = ( + FD3BC66B29BA00D600B19FBE /* EntitiesListTemplate.swift */, + FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */, + ); + path = Templates; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -5437,13 +5499,16 @@ 1187DE4224D77CCC00F0A6A6 /* NFCTagViewController.swift in Sources */, D0EEF324214DF2B700D1D360 /* Utils.swift in Sources */, 1101D7F92621479200AAE617 /* SettingsButtonRow.swift in Sources */, + FD3BC66C29BA00D600B19FBE /* EntitiesListTemplate.swift in Sources */, B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */, B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */, 119D765F2492F8FA00183C5F /* UIApplication+BackgroundTask.swift in Sources */, 11195F6F267EFC8E003DF674 /* NotificationManagerLocalPushInterfaceDirect.swift in Sources */, + FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */, 11C4629424B189B100031902 /* NotificationRateLimitsAPI.swift in Sources */, 1161C01B24D7634300A0E3C4 /* NFCListViewController.swift in Sources */, 11A71C6B24A463FC00D9565F /* ZoneManagerState.swift in Sources */, + FD3BC66E29BA010A00B19FBE /* DomainsListTemplate.swift in Sources */, 1185DFAF271FF53800ED7D9A /* OnboardingAuthStepRegister.swift in Sources */, 11F20BC5274B06C100DFB163 /* ServerSelectRow.swift in Sources */, 1130F532253A1E7400F371BE /* ComplicationListViewController.swift in Sources */, @@ -5629,11 +5694,13 @@ 110ED59025A6743900489AF7 /* ConnectivityWrapper.swift in Sources */, 1110836924AFEFA60027A67A /* Promise+WebhookJson.swift in Sources */, 1164D9DF25FB1B9800515E8A /* UIBarButtonItem+Additions.swift in Sources */, + 42F1DA592B46FDD8002729BC /* HATypedRequest+CarPlay.swift in Sources */, 11B38EF6275C54A300205C7B /* PickAServerError.swift in Sources */, B67CE8AF22200F220034C1D0 /* ObjectMapperTransformers.swift in Sources */, 11AF4D13249C7E08006C74C0 /* ActivitySensor.swift in Sources */, 11E5CF8224BBCE1B009AC30F /* ProcessInfo+BackgroundTask.swift in Sources */, 11AF4D1D249C8AA0006C74C0 /* BatterySensor.swift in Sources */, + 42CE8FBA2B46D67A00C707F9 /* HAEntity+CarPlay.swift in Sources */, B67CE8A922200F220034C1D0 /* SettingsStore.swift in Sources */, 11AF4D26249D1931006C74C0 /* LastUpdateSensor.swift in Sources */, 11EE9B4A24C5116F00404AF8 /* ModelManager.swift in Sources */, @@ -5646,6 +5713,7 @@ 11169BC6262BE45F005EF90A /* UNNotificationContent+Additions.swift in Sources */, B672334B225DDF410031D629 /* Event.swift in Sources */, 11B38EF5275C54A300205C7B /* GetCameraImageIntentHandler.swift in Sources */, + 42CE8FB72B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */, 11521BBD25400284009C5C72 /* CrashReporter.swift in Sources */, 113A8D4A283C7B1700B9DA32 /* PeriodicUpdateManager.swift in Sources */, B6872E642226841400C475D1 /* MobileAppRegistrationRequest.swift in Sources */, @@ -5705,6 +5773,7 @@ 11E1639B250B1B760076D612 /* OnboardingStateObservation.swift in Sources */, 115BC8292676F44E00452430 /* FocusSensor.swift in Sources */, B6221F6522266F9F00502A30 /* WebhookRequest.swift in Sources */, + 42CE8FBB2B46DB6200C707F9 /* Assets.swift in Sources */, 11B38EF4275C54A300205C7B /* WidgetActionsIntentHandler.swift in Sources */, 11F3847C24FB27FC00CB0D74 /* DeviceWrapperBatteryObserver.swift in Sources */, 11B38EFA275C54A300205C7B /* FocusStatusIntentHandler.swift in Sources */, @@ -5733,6 +5802,7 @@ B67CE89522200F220034C1D0 /* LocationHistory.swift in Sources */, 491E990025D543560077BBE3 /* LogbookEntry.swift in Sources */, 11B38EF2275C54A300205C7B /* CallServiceIntentHandler.swift in Sources */, + 42CE8FB12B46C3DA00C707F9 /* CoreStrings+Values.swift in Sources */, B67CE8A722200F220034C1D0 /* HAAPI+RequestHelpers.swift in Sources */, 11C4628924B109C100031902 /* WebhookResponseLocation.swift in Sources */, 11C4628C24B1230E00031902 /* WebhookResponseServiceCall.swift in Sources */, @@ -5757,6 +5827,7 @@ 1104FC9225322C1800B8BE34 /* Dictionary+Additions.swift in Sources */, 118261F824F8D6B0000795C6 /* SensorProviderDependencies.swift in Sources */, 11C8E8AD24F36535003E7F89 /* DeviceWrapper.swift in Sources */, + 42CE8FB32B46C46F00C707F9 /* Domain.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5796,6 +5867,7 @@ 118BDA8825A6DBBA00731016 /* FrontmostAppSensor.swift in Sources */, 11EE9B4624C4E01500404AF8 /* SharedPlist.swift in Sources */, 1110836824AFEFA60027A67A /* Promise+WebhookJson.swift in Sources */, + 42CE8FB22B46C46E00C707F9 /* Domain.swift in Sources */, 42F5CAE72B10CDC900409816 /* CardView.swift in Sources */, 42DD84132B14ACAB00936F16 /* Color+ColorAsset.swift in Sources */, 115560E827011E3300A8F818 /* HAPanel.swift in Sources */, @@ -5814,6 +5886,7 @@ 42CE8FA92B45D1E900C707F9 /* FrontendStrings.swift in Sources */, D0EEF335214EB77100D1D360 /* CLLocation+Extensions.swift in Sources */, 11AF4D1F249C8AF1006C74C0 /* ConnectivitySensor.swift in Sources */, + 42CE8FB92B46D67A00C707F9 /* HAEntity+CarPlay.swift in Sources */, 11B38EED275C54A200205C7B /* RenderTemplateIntentHandler.swift in Sources */, B6723341225DB82E0031D629 /* KeyedDecodingContainer+JSON.swift in Sources */, 11C4629124B14E6B00031902 /* XCGLogger+UNNotification.swift in Sources */, @@ -5833,6 +5906,7 @@ B6B74CBD228399AB00D58A68 /* Action.swift in Sources */, 11CB98CA249E62E700B05222 /* Version+HA.swift in Sources */, 11EE9B4924C5116F00404AF8 /* ModelManager.swift in Sources */, + 42CE8FB62B46D14C00C707F9 /* FrontendStrings+Values.swift in Sources */, D0C3DC142134CD4E000C9EE1 /* CMMotion+StringExtensions.swift in Sources */, B6872E662226842100C475D1 /* MobileAppRegistrationResponse.swift in Sources */, D0EEF305214DD0D400D1D360 /* UIColor+HA.swift in Sources */, @@ -5915,6 +5989,7 @@ 11C4628E24B128EF00031902 /* WebhookResponseUnhandled.swift in Sources */, 1121CD4C271295AD0071C2AA /* Style.swift in Sources */, 116570772702B0F6003906A7 /* DiskCache.swift in Sources */, + 42F1DA582B46FDD8002729BC /* HATypedRequest+CarPlay.swift in Sources */, 11657050270188E4003906A7 /* URLComponents+WidgetAuthenticity.swift in Sources */, B672334A225DDF410031D629 /* Event.swift in Sources */, B6C091232151F90300A326DC /* LocationHistory.swift in Sources */, @@ -5933,6 +6008,7 @@ B6B74CBA2283983800D58A68 /* CLKComplication+Strings.swift in Sources */, 119385A4249E8E360097F497 /* StorageSensor.swift in Sources */, D05A4D32216DD206009FD1EB /* MJPEGStreamer.swift in Sources */, + 42CE8FB02B46C3D900C707F9 /* CoreStrings+Values.swift in Sources */, 11C4628B24B1230E00031902 /* WebhookResponseServiceCall.swift in Sources */, D0EEF30A214DD64C00D1D360 /* UIImage+Icons.swift in Sources */, D0EEF303214D8F0300D1D360 /* String+HA.swift in Sources */, diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index a084580c0..769653013 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -184,10 +184,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions ) -> UISceneConfiguration { - let activity = options.userActivities - .compactMap { SceneActivity(activityIdentifier: $0.activityType) } - .first ?? .webView - return activity.configuration + if #available(iOS 16.0, *), connectingSceneSession.role == UISceneSession.Role.carTemplateApplication { + return SceneActivity.carPlay.configuration + } else { + let activity = options.userActivities + .compactMap { SceneActivity(activityIdentifier: $0.activityType) } + .first ?? .webView + return activity.configuration + } } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index 2d28a7656..ece3d295b 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -37,6 +37,17 @@ UISceneConfigurations + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneClassName + CPTemplateApplicationScene + UISceneConfigurationName + CarPlay + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate + + UIWindowSceneSessionRoleApplication diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index c064bcb09..556dfb853 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -11,6 +11,7 @@ "about.home_assistant_on_facebook.title" = "Home Assistant on Facebook"; "about.home_assistant_on_twitter.title" = "Home Assistant on Twitter"; "about.logo.app_title" = "Home Assistant Companion"; +"about.logo.title" = "Home Assistant"; "about.logo.tagline" = "Awaken Your Home"; "about.review.title" = "Leave a review"; "about.title" = "About"; @@ -32,6 +33,7 @@ "alerts.auth_required.message" = "The server has rejected your credentials, and you must sign in again to continue."; "alerts.auth_required.title" = "You must sign in to continue"; "alerts.confirm.cancel" = "Cancel"; +"alerts.confirm.confirm" = "Confirm"; "alerts.confirm.ok" = "OK"; "alerts.deprecations.notification_category.message" = "You must migrate to actions defined in the notification itself before %1$@."; "alerts.deprecations.notification_category.title" = "Notification Categories are deprecated"; @@ -784,4 +786,12 @@ Home Assistant is free and open source home automation software with a focus on "widgets.open_page.description" = "Open a frontend page in Home Assistant."; "widgets.open_page.not_configured" = "No Pages Available"; "widgets.open_page.title" = "Open Page"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; +"carplay.navigation.button.next" = "Next"; +"carplay.navigation.button.previous" = "Previous"; +"carplay.labels.servers" = "Servers"; +"carplay.labels.empty_domain_list" = "No domains available"; +"carplay.labels.no_servers_available" = "No servers available. Add a server in the app."; +"carplay.labels.already_added_server" = "Already added"; +"carplay.lock.confirmation.title" = "Are you sure you want to perform lock action on %@?"; +"carplay.unlock.confirmation.title" = "Are you sure you want to perform unlock action on %@?"; diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift new file mode 100644 index 000000000..646307244 --- /dev/null +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -0,0 +1,221 @@ +import CarPlay +import Communicator +import HAKit +import PromiseKit +import Shared + +public protocol EntitiesStateSubscription { + func subscribe() + func unsubscribe() +} + +@available(iOS 16.0, *) +class CarPlaySceneDelegate: UIResponder { + private var interfaceController: CPInterfaceController? + private var entities: HACache>? + private var domainsListTemplate: DomainsListTemplate? + private var serverId: Identifier? + + private let carPlayPreferredServerKey = "carPlay-server" + + private func setServer(server: Server) { + serverId = server.identifier + prefs.set(server.identifier.rawValue, forKey: carPlayPreferredServerKey) + setDomainListTemplate(for: server) + updateServerListButton() + } + + private func updateServerListButton() { + domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + } + + @objc private func updateServerList() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.updateServerListButton() + if self.serverId == nil { + /// No server is selected + guard let server = self.getServer() else { + Current.Log.info("No server connected") + return + } + self.setServer(server: server) + } + } + } + + private func showNoServerAlert() { + guard interfaceController?.presentedTemplate == nil else { + return + } + + let loginAlertAction = CPAlertAction(title: L10n.Carplay.Labels.alreadyAddedServer, style: .default) { _ in + if !Current.servers.all.isEmpty { + self.interfaceController?.dismissTemplate(animated: true, completion: nil) + } + } + let alertTemplate = CPAlertTemplate( + titleVariants: [L10n.Carplay.Labels.noServersAvailable], + actions: [loginAlertAction] + ) + interfaceController?.presentTemplate(alertTemplate, animated: true, completion: nil) + } + + private func setDomainListTemplate(for server: Server) { + guard let interfaceController else { return } + + let entities = Current.api(for: server).connection.caches.states + + domainsListTemplate = DomainsListTemplate( + title: server.info.name, + entities: entities, + serverButtonHandler: { [weak self] _ in + self?.setServerListTemplate() + }, + server: server + ) + + guard let domainsListTemplate else { return } + + domainsListTemplate.interfaceController = interfaceController + + interfaceController.setRootTemplate(domainsListTemplate.template, animated: true, completion: nil) + domainsListTemplate.updateSections() + } + + private func setServerListTemplate() { + var serverList: [CPListItem] = [] + for server in Current.servers.all { + let serverItem = CPListItem( + text: server.info.name, + detailText: "\(server.info.connection.activeURLType.description) - \(server.info.connection.activeURL().absoluteString)" + ) + serverItem.handler = { [weak self] _, completion in + self?.setServer(server: server) + if let templates = self?.interfaceController?.templates, templates.count > 1 { + self?.interfaceController?.popTemplate(animated: true, completion: nil) + } + completion() + } + serverItem.accessoryType = serverId == server.identifier ? .cloud : .none + serverList.append(serverItem) + } + let section = CPListSection(items: serverList) + let serverListTemplate = CPListTemplate(title: L10n.Carplay.Labels.servers, sections: [section]) + interfaceController?.pushTemplate(serverListTemplate, animated: true, completion: nil) + } + + private func setEmptyTemplate(interfaceController: CPInterfaceController) { + interfaceController.setRootTemplate(CPInformationTemplate( + title: L10n.About.Logo.title, + layout: .leading, + items: [], + actions: [] + ), animated: true, completion: nil) + } + + /// Get server for ID or first server available + private func getServer(id: Identifier? = nil) -> Server? { + guard let id = id else { + return Current.servers.all.first + } + return Current.servers.server(for: id) + } +} + +// MARK: - CPTemplateApplicationSceneDelegate + +@available(iOS 16.0, *) +extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate { + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController + ) { + self.interfaceController = interfaceController + self.interfaceController?.delegate = self + + if let serverIdentifier = prefs.string(forKey: carPlayPreferredServerKey), + let selectedServer = Current.servers.server(forServerIdentifier: serverIdentifier) { + setServer(server: selectedServer) + } else if let server = getServer() { + setServer(server: server) + } else { + setEmptyTemplate(interfaceController: interfaceController) + } + + updateServerList() + + NotificationCenter.default.addObserver( + self, + selector: #selector(updateServerList), + name: HAConnectionState.didTransitionToStateNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(updateServerList), + name: HomeAssistantAPI.didConnectNotification, + object: nil + ) + + /// Observer for servers list changes + Current.servers.add(observer: self) + + if Current.servers.all.isEmpty { + showNoServerAlert() + } + } + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didDisconnect interfaceController: CPInterfaceController, + from window: CPWindow + ) { + NotificationCenter.default.removeObserver(self) + Current.servers.remove(observer: self) + } +} + +// MARK: - ServerObserver + +@available(iOS 16.0, *) +extension CarPlaySceneDelegate: ServerObserver { + func serversDidChange(_ serverManager: ServerManager) { + defer { + updateServerListButton() + } + + guard let server = getServer(id: serverId) else { + serverId = nil + + if let server = getServer() { + setServer(server: server) + } else if interfaceController?.presentedTemplate != nil { + interfaceController?.dismissTemplate(animated: true, completion: nil) + } else { + showNoServerAlert() + } + + return + } + setServer(server: server) + } +} + +@available(iOS 16.0, *) +extension CarPlaySceneDelegate: CPInterfaceControllerDelegate { + func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) { + domainsListTemplate?.templateWillDisappear(template: aTemplate) + } + + func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) { + domainsListTemplate?.templateWillAppear(template: aTemplate) + } +} + +protocol CarPlayTemplateProvider { + var template: CPTemplate { get set } + func templateWillDisappear(template: CPTemplate) + func templateWillAppear(template: CPTemplate) +} diff --git a/Sources/App/Scenes/SceneActivity.swift b/Sources/App/Scenes/SceneActivity.swift index 733506e25..92d8546af 100644 --- a/Sources/App/Scenes/SceneActivity.swift +++ b/Sources/App/Scenes/SceneActivity.swift @@ -4,6 +4,7 @@ enum SceneActivity: CaseIterable { case webView case settings case about + case carPlay init(activityIdentifier: String) { self = Self.allCases.first(where: { $0.activityIdentifier == activityIdentifier }) ?? .webView @@ -22,6 +23,7 @@ enum SceneActivity: CaseIterable { case .settings: return "ha.settings" case .webView: return "ha.webview" case .about: return "ha.about" + case .carPlay: return "ha.carPlay" } } @@ -30,10 +32,14 @@ enum SceneActivity: CaseIterable { case .webView: return "WebView" case .settings: return "Settings" case .about: return "About" + case .carPlay: return "CarPlay" } } var configuration: UISceneConfiguration { - .init(name: configurationName, sessionRole: .windowApplication) + switch self { + case .webView, .settings, .about: return .init(name: configurationName, sessionRole: .windowApplication) + case .carPlay: return .init(name: configurationName, sessionRole: .carTemplateApplication) + } } } diff --git a/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift new file mode 100644 index 000000000..8e81a72e9 --- /dev/null +++ b/Sources/Shared/CarPlay/HATypedRequest+CarPlay.swift @@ -0,0 +1,93 @@ +import Foundation +import HAKit + +extension HATypedRequest { + static func toggleDomain( + domain: Domain, + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "domain": domain.rawValue, + "service": "toggle", + "target": [ + "entity_id": entityId, + ], + ] + )) + } + + static func runScript( + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "domain": "script", + "service": entityId.replacingOccurrences(of: "script.", with: ""), + ] + )) + } + + static func applyScene( + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "domain": "scene", + "service": "turn_on", + "target": [ + "entity_id": entityId, + ], + ] + )) + } + + static func pressButton( + domain: Domain, + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "domain": domain.rawValue, + "service": "press", + "target": [ + "entity_id": entityId, + ], + ] + )) + } + + static func lockLock( + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "domain": "lock", + "service": "lock", + "target": [ + "entity_id": entityId, + ], + ] + )) + } + + static func unlockLock( + entityId: String + ) -> HATypedRequest { + HATypedRequest(request: .init( + type: "call_service", + data: [ + "domain": "lock", + "service": "unlock", + "target": [ + "entity_id": entityId, + ], + ] + )) + } +} diff --git a/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift new file mode 100644 index 000000000..05b9a286b --- /dev/null +++ b/Sources/Shared/Common/Extensions/HAEntity+CarPlay.swift @@ -0,0 +1,203 @@ +import Foundation +import HAKit +import PromiseKit +import SwiftUI +import UIKit + +public extension HAEntity { + func onPress(for api: HomeAssistantAPI) -> Promise { + var request: HATypedRequest? + switch Domain(rawValue: domain) { + case .button: + request = .pressButton(domain: .button, entityId: entityId) + case .cover: + request = .toggleDomain(domain: .cover, entityId: entityId) + case .inputBoolean: + request = .toggleDomain(domain: .inputBoolean, entityId: entityId) + case .inputButton: + request = .pressButton(domain: .inputButton, entityId: entityId) + case .light: + request = .toggleDomain(domain: .light, entityId: entityId) + case .scene: + request = .applyScene(entityId: entityId) + case .script: + request = .runScript(entityId: entityId) + case .switch: + request = .toggleDomain(domain: .switch, entityId: entityId) + case .lock: + guard let state = Domain.State(rawValue: state) else { return .value } + switch state { + case .unlocking, .unlocked, .opening: + request = .lockLock(entityId: entityId) + case .locked, .locking: + request = .unlockLock(entityId: entityId) + default: + break + } + case .none: + break + } + if let request { + return api.connection.send(request).promise.map { _ in () } + } else { + return .value + } + } + + func getIcon(size: CGSize = CGSize(width: 64, height: 64)) -> UIImage? { + var image = MaterialDesignIcons.bookmarkIcon + var tint: UIColor = .white + + if let icon = attributes.icon?.normalizingIconString { + image = MaterialDesignIcons(named: icon) + } else { + guard let domain = Domain(rawValue: domain) else { return nil } + switch domain { + case .button: + image = getButtonIcon() + case .cover: + image = getCoverIcon() + case .inputBoolean: + image = getInputBooleanIcon() + case .inputButton: + image = MaterialDesignIcons.gestureTapButtonIcon + case .light: + image = MaterialDesignIcons.lightbulbIcon + case .lock: + image = getLockIcon() + case .scene: + image = MaterialDesignIcons.paletteOutlineIcon + case .script: + image = MaterialDesignIcons.scriptTextOutlineIcon + case .switch: + image = getSwitchIcon() + } + } + + if let state = Domain.State(rawValue: state) { + if [.on, .open, .opening, .unlocked, .unlocking].contains(state) { + tint = Constants.tintColor + } else if [.unavailable, .unknown].contains(state) { + tint = .gray + } + } + + return image.image(ofSize: size, color: tint) + } + + private func getInputBooleanIcon() -> MaterialDesignIcons { + if !entityId.hasSuffix(".ha_ios_placeholder"), let compareState = Domain.State(rawValue: state) { + if compareState == .on { + return MaterialDesignIcons.checkCircleOutlineIcon + } else { + return MaterialDesignIcons.closeCircleOutlineIcon + } + } else { + return MaterialDesignIcons.toggleSwitchOutlineIcon + } + } + + private func getButtonIcon() -> MaterialDesignIcons { + guard let deviceClass = attributes.dictionary["device_class"] as? String else { return MaterialDesignIcons.gestureTapButtonIcon } + if deviceClass == "restart" { + return MaterialDesignIcons.restartIcon + } else if deviceClass == "update" { + return MaterialDesignIcons.packageUpIcon + } else { + return MaterialDesignIcons.gestureTapButtonIcon + } + } + + private func getLockIcon() -> MaterialDesignIcons { + guard let compareState = Domain.State(rawValue: state) else { return MaterialDesignIcons.lockIcon } + switch compareState { + case .unlocked: + return MaterialDesignIcons.lockOpenIcon + case .jammed: + return MaterialDesignIcons.lockAlertIcon + case .locking, .unlocking: + return MaterialDesignIcons.lockClockIcon + default: + return MaterialDesignIcons.lockIcon + } + } + + private func getSwitchIcon() -> MaterialDesignIcons { + guard let compareState = Domain.State(rawValue: state) else { return MaterialDesignIcons.lightSwitchIcon } + if !entityId.hasSuffix(".ha_ios_placeholder") { + let deviceClass = attributes.dictionary["device_class"] as? String + switch deviceClass { + case "outlet": + return compareState == .on ? MaterialDesignIcons.powerPlugIcon : MaterialDesignIcons + .powerPlugOffIcon + case "switch": + return compareState == .on ? MaterialDesignIcons.toggleSwitchIcon : MaterialDesignIcons + .toggleSwitchOffIcon + default: + return MaterialDesignIcons.flashIcon + } + } else { + return MaterialDesignIcons.lightSwitchIcon + } + } + + private func getCoverIcon() -> MaterialDesignIcons { + let device_class = attributes.dictionary["device_class"] as? String + let state = state + + guard let state = Domain.State(rawValue: state) else { return MaterialDesignIcons.bookmarkIcon } + + switch device_class { + case "garage": + switch state { + case .opening: return MaterialDesignIcons.arrowUpBoxIcon + case .closing: return MaterialDesignIcons.arrowDownBoxIcon + case .closed: return MaterialDesignIcons.garageIcon + default: return MaterialDesignIcons.garageOpenIcon + } + case "gate": + switch state { + case .opening: return MaterialDesignIcons.gateArrowRightIcon + case .closed: return MaterialDesignIcons.gateIcon + default: return MaterialDesignIcons.gateOpenIcon + } + case "door": + return state == .open ? MaterialDesignIcons.doorOpenIcon : MaterialDesignIcons.doorClosedIcon + case "damper": + return state == .open ? MaterialDesignIcons.circleIcon : MaterialDesignIcons.circleSlice8Icon + case "shutter": + switch state { + case .opening: return MaterialDesignIcons.arrowUpBoxIcon + case .closing: return MaterialDesignIcons.arrowDownBoxIcon + case .closed: return MaterialDesignIcons.windowShutterIcon + default: return MaterialDesignIcons.windowShutterOpenIcon + } + case "curtain": + switch state { + case .opening: return MaterialDesignIcons.arrowSplitVerticalIcon + case .closing: return MaterialDesignIcons.arrowCollapseHorizontalIcon + case .closed: return MaterialDesignIcons.curtainsClosedIcon + default: return MaterialDesignIcons.curtainsIcon + } + case "blind", "shade": + switch state { + case .opening: return MaterialDesignIcons.arrowUpBoxIcon + case .closing: return MaterialDesignIcons.arrowDownBoxIcon + case .closed: return MaterialDesignIcons.blindsIcon + default: return MaterialDesignIcons.blindsOpenIcon + } + default: + switch state { + case .open: return MaterialDesignIcons.arrowUpBoxIcon + case .closing: return MaterialDesignIcons.arrowDownBoxIcon + case .closed: return MaterialDesignIcons.windowClosedIcon + default: return MaterialDesignIcons.windowOpenIcon + } + } + } + + var localizedState: String { + CoreStrings.getDomainStateLocalizedTitle(state: state) ?? FrontendStrings + .getDefaultStateLocalizedTitle(state: state) ?? state + } +} diff --git a/Sources/Shared/Domain/Domain.swift b/Sources/Shared/Domain/Domain.swift new file mode 100644 index 000000000..00eafc743 --- /dev/null +++ b/Sources/Shared/Domain/Domain.swift @@ -0,0 +1,112 @@ +import Foundation +import UIKit + +public enum Domain: String, CaseIterable { + case button + case cover + case inputBoolean = "input_boolean" + case inputButton = "input_button" + case light + case lock + case scene + case script + case `switch` + // TODO: Map more domains + + public enum State: String { + case locked + case unlocked + case jammed + case locking + case unlocking + + case on + case off + + case opening + case closing + case closed + case open + + case unknown + case unavailable + } + + public var states: [State] { + var states: [State] = [] + switch self { + case .button: + states = [] + case .cover: + states = [.open, .closed, .opening, .closing] + case .inputBoolean: + states = [] + case .inputButton: + states = [] + case .light: + states = [.on, .off] + case .lock: + states = [.locked, .unlocked, .jammed, .locking, .unlocking] + case .scene: + states = [] + case .script: + states = [] + case .switch: + states = [.on, .off] + } + + states.append(contentsOf: [.unavailable, .unknown]) + return states + } + + public var icon: UIImage { + var image = MaterialDesignIcons.bookmarkIcon + switch self { + case .button: + image = MaterialDesignIcons.gestureTapButtonIcon + case .cover: + image = MaterialDesignIcons.curtainsIcon + case .inputBoolean: + image = MaterialDesignIcons.toggleSwitchOutlineIcon + case .inputButton: + image = MaterialDesignIcons.gestureTapButtonIcon + case .light: + image = MaterialDesignIcons.lightbulbIcon + case .lock: + image = MaterialDesignIcons.lockIcon + case .scene: + image = MaterialDesignIcons.paletteOutlineIcon + case .script: + image = MaterialDesignIcons.scriptTextOutlineIcon + case .switch: + image = MaterialDesignIcons.lightSwitchIcon + } + return image.image(ofSize: .init(width: 64, height: 64), color: .white) + } + + public var localizedDescription: String { + CoreStrings.getDomainLocalizedTitle(domain: self) + } + + public var isCarPlaySupported: Bool { + carPlaySupportedDomains.contains(self) + } +} + +// MARK: - CarPlay + +public extension Domain { + var carPlaySupportedDomains: [Domain] { + [ + .button, + .cover, + .inputBoolean, + .inputButton, + .light, + .lock, + .scene, + .script, + .switch, + ] + } +} diff --git a/Sources/Shared/Environment/LocalizedManager.swift b/Sources/Shared/Environment/LocalizedManager.swift index 75eac4747..d69c6afc8 100644 --- a/Sources/Shared/Environment/LocalizedManager.swift +++ b/Sources/Shared/Environment/LocalizedManager.swift @@ -38,6 +38,14 @@ public class LocalizedManager { return result } + public func core(_ key: String) -> String? { + let result = string(key, "Core") + guard result != key else { + return nil + } + return result + } + public func string(_ key: String, _ table: String) -> String { let defaultValue = bundle.localizedString(forKey: key, value: nil, table: table) let request = StringProviderRequest(key: key, table: table, defaultValue: defaultValue) diff --git a/Sources/Shared/Resources/Swiftgen/CoreStrings+Values.swift b/Sources/Shared/Resources/Swiftgen/CoreStrings+Values.swift new file mode 100644 index 000000000..258165dd4 --- /dev/null +++ b/Sources/Shared/Resources/Swiftgen/CoreStrings+Values.swift @@ -0,0 +1,21 @@ +// +// CoreStrings+Assemble.swift +// App +// +// Created by Bruno Pantaleão on 04/01/2024. +// Copyright © 2024 Home Assistant. All rights reserved. +// + +import Foundation + +public extension CoreStrings { + static func getDomainLocalizedTitle(domain: Domain) -> String { + let key = "component::\(domain.rawValue)::title" + return Current.localized.core(key) ?? domain.rawValue + } + + static func getDomainStateLocalizedTitle(state: String) -> String? { + let key = "common::state::\(state)" + return Current.localized.core(key) + } +} diff --git a/Sources/Shared/Resources/Swiftgen/FrontendStrings+Values.swift b/Sources/Shared/Resources/Swiftgen/FrontendStrings+Values.swift new file mode 100644 index 000000000..f4b28e158 --- /dev/null +++ b/Sources/Shared/Resources/Swiftgen/FrontendStrings+Values.swift @@ -0,0 +1,16 @@ +// +// FrontendStrings+Values.swift +// App +// +// Created by Bruno Pantaleão on 04/01/2024. +// Copyright © 2024 Home Assistant. All rights reserved. +// + +import Foundation + +public extension FrontendStrings { + static func getDefaultStateLocalizedTitle(state: String) -> String? { + let key = "state::default::\(state)" + return Current.localized.frontend(key) + } +} diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index cefb56263..8fe8194c8 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -109,6 +109,8 @@ public enum L10n { public static var appTitle: String { return L10n.tr("Localizable", "about.logo.app_title") } /// Awaken Your Home public static var tagline: String { return L10n.tr("Localizable", "about.logo.tagline") } + /// Home Assistant + public static var title: String { return L10n.tr("Localizable", "about.logo.title") } } public enum Review { /// Leave a review @@ -181,6 +183,8 @@ public enum L10n { public enum Confirm { /// Cancel public static var cancel: String { return L10n.tr("Localizable", "alerts.confirm.cancel") } + /// Confirm + public static var confirm: String { return L10n.tr("Localizable", "alerts.confirm.confirm") } /// OK public static var ok: String { return L10n.tr("Localizable", "alerts.confirm.ok") } } @@ -229,6 +233,43 @@ public enum L10n { } } + public enum Carplay { + public enum Labels { + /// Already added + public static var alreadyAddedServer: String { return L10n.tr("Localizable", "carplay.labels.already_added_server") } + /// No domains available + public static var emptyDomainList: String { return L10n.tr("Localizable", "carplay.labels.empty_domain_list") } + /// No servers available. Add a server in the app. + public static var noServersAvailable: String { return L10n.tr("Localizable", "carplay.labels.no_servers_available") } + /// Servers + public static var servers: String { return L10n.tr("Localizable", "carplay.labels.servers") } + } + public enum Lock { + public enum Confirmation { + /// Are you sure you want to perform lock action on %@? + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "carplay.lock.confirmation.title", String(describing: p1)) + } + } + } + public enum Navigation { + public enum Button { + /// Next + public static var next: String { return L10n.tr("Localizable", "carplay.navigation.button.next") } + /// Previous + public static var previous: String { return L10n.tr("Localizable", "carplay.navigation.button.previous") } + } + } + public enum Unlock { + public enum Confirmation { + /// Are you sure you want to perform unlock action on %@? + public static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "carplay.unlock.confirmation.title", String(describing: p1)) + } + } + } + } + public enum ClError { public enum Description { /// Deferred mode is not supported for the requested accuracy. diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift new file mode 100644 index 000000000..88aec044c --- /dev/null +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -0,0 +1,113 @@ +import CarPlay +import Foundation +import HAKit +import Shared + +@available(iOS 16.0, *) +class DomainsListTemplate: CarPlayTemplateProvider { + private let title: String + private let entitiesCachedStates: HACache + private let serverButtonHandler: CPBarButtonHandler? + private let server: Server + + private var domainList: [String] = [] + private var childTemplateProvider: CarPlayTemplateProvider? + + weak var interfaceController: CPInterfaceController? + + var template: CPTemplate + + init( + title: String, + entities: HACache, + serverButtonHandler: CPBarButtonHandler? = nil, + server: Server + ) { + self.title = title + self.entitiesCachedStates = entities + self.serverButtonHandler = serverButtonHandler + self.server = server + + let listTemplate = CPListTemplate(title: title, sections: []) + listTemplate.emptyViewSubtitleVariants = [L10n.Carplay.Labels.emptyDomainList] + self.template = listTemplate + } + + func setServerListButton(show: Bool) { + guard let listTemplate = template as? CPListTemplate else { + return + } + + if show { + listTemplate + .trailingNavigationBarButtons = + [CPBarButton(title: L10n.Carplay.Labels.servers, handler: serverButtonHandler)] + } else { + listTemplate.trailingNavigationBarButtons.removeAll() + } + } + + func updateSections() { + var items: [CPListItem] = [] + let entityDomains = Set(entitiesCachedStates.value?.all.map(\.domain) ?? []) + let domains = entityDomains.filter { Domain(rawValue: $0)?.isCarPlaySupported ?? false }.sorted(by: { d1, d2 in + d1 < d2 + }) + + domains.forEach { domain in + guard let domain = Domain(rawValue: domain) else { return } + let itemTitle = domain.localizedDescription + let listItem = CPListItem( + text: itemTitle, + detailText: nil, + image: domain.icon + ) + listItem.accessoryType = CPListItemAccessoryType.disclosureIndicator + listItem.handler = { [weak self] _, completion in + self?.listItemHandler(domain: domain.rawValue) + completion() + } + + items.append(listItem) + } + + domainList = domains + (template as? CPListTemplate)?.updateSections([CPListSection(items: items)]) + } + + func templateWillDisappear(template: CPTemplate) { + if self.template == template { + entitiesSubscriptionToken?.cancel() + } else { + childTemplateProvider?.templateWillDisappear(template: template) + } + } + + func templateWillAppear(template: CPTemplate) { + if self.template == template { + entitiesSubscriptionToken = entitiesCachedStates.subscribe { [weak self] _, _ in + self?.updateSections() + } + } + } + + var entitiesSubscriptionToken: HACancellable? + + private func listItemHandler(domain: String) { + let entitiesListTemplate = EntitiesListTemplate( + title: Domain(rawValue: domain)?.localizedDescription ?? domain, + domain: domain, + server: server, + entitiesCachedStates: entitiesCachedStates + ) + + entitiesListTemplate.interfaceController = interfaceController + + childTemplateProvider = entitiesListTemplate + interfaceController?.pushTemplate( + entitiesListTemplate.getTemplate(), + animated: true, + completion: nil + ) + } +} diff --git a/Sources/Vehicle/Templates/EntitiesListTemplate.swift b/Sources/Vehicle/Templates/EntitiesListTemplate.swift new file mode 100644 index 000000000..b6674c304 --- /dev/null +++ b/Sources/Vehicle/Templates/EntitiesListTemplate.swift @@ -0,0 +1,185 @@ +import CarPlay +import Foundation +import HAKit +import PromiseKit +import Shared + +@available(iOS 16.0, *) +final class EntitiesListTemplate: CarPlayTemplateProvider { + enum GridPage { + case Next + case Previous + } + + enum CPEntityError: Error { + case unknown + } + + private let entityIconSize: CGSize = .init(width: 64, height: 64) + private let domain: String + private var server: Server + private let entitiesCachedStates: HACache + private var currentPage: Int = 0 + + private var itemsPerPage: Int = CPListTemplate.maximumItemCount + private var entitiesSubscriptionToken: HACancellable? + + var template: CPTemplate + weak var interfaceController: CPInterfaceController? + + init(title: String, domain: String, server: Server, entitiesCachedStates: HACache) { + self.domain = domain + self.server = server + self.entitiesCachedStates = entitiesCachedStates + self.template = CPListTemplate(title: title, sections: []) + } + + public func getTemplate() -> CPTemplate { + defer { + updateListItems() + entitiesSubscriptionToken = entitiesCachedStates.subscribe { [weak self] _, _ in + self?.updateListItems() + } + } + + return template + } + + private func updateListItems() { + guard let entities = entitiesCachedStates.value, let listTemplate = template as? CPListTemplate else { return } + + let entitiesFiltered = entities.all.filter { $0.domain == domain } + let entitiesSorted = entitiesFiltered + .sorted(by: { $0.attributes.friendlyName ?? $0.entityId < $1.attributes.friendlyName ?? $1.entityId }) + + let startIndex = currentPage * itemsPerPage + let endIndex = min(startIndex + itemsPerPage, entitiesSorted.count) + let entitiesToShow = Array(entitiesSorted[startIndex ..< endIndex]) + + var items: [CPListItem] = [] + + entitiesToShow.forEach { entity in + let item = CPListItem( + text: entity.attributes.friendlyName ?? entity.entityId, + detailText: entity.localizedState, + image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil) + ) + item.handler = { _, completion in + firstly { [weak self] () -> Promise in + guard let self = self else { return .init(error: CPEntityError.unknown) } + + let api = Current.api(for: self.server) + + if let domain = Domain(rawValue: entity.domain), domain == .lock { + self.displayLockConfirmation(entity: entity, completion: { + entity.onPress(for: api).catch { error in + Current.Log.error("Received error from callService during onPress call: \(error)") + } + }) + return .value + } else { + return entity.onPress(for: api) + } + }.done { + completion() + }.catch { error in + Current.Log.error("Received error from callService during onPress call: \(error)") + completion() + } + } + + items.append(item) + } + + // Add pagination buttons if needed + if entitiesSorted.count > itemsPerPage { + listTemplate.trailingNavigationBarButtons = getPageButtons( + endIndex: endIndex, + currentPage: currentPage, + totalCount: entitiesSorted.count + ) + } + + listTemplate.updateSections([CPListSection(items: items)]) + } + + private func displayLockConfirmation(entity: HAEntity, completion: @escaping () -> Void) { + guard let state = Domain.State(rawValue: entity.state) else { return } + var title = "" + switch state { + case .locked, .locking: + title = L10n.Carplay.Unlock.Confirmation.title(entity.attributes.friendlyName ?? entity.entityId) + default: + title = L10n.Carplay.Lock.Confirmation.title(entity.attributes.friendlyName ?? entity.entityId) + } + + let alert = CPAlertTemplate(titleVariants: [title], actions: [ + .init(title: L10n.Alerts.Confirm.cancel, style: .cancel, handler: { [weak self] _ in + self?.interfaceController?.dismissTemplate(animated: true, completion: nil) + }), + .init(title: L10n.Alerts.Confirm.confirm, style: .destructive, handler: { [weak self] _ in + completion() + self?.interfaceController?.dismissTemplate(animated: true, completion: nil) + }), + ]) + + interfaceController?.presentTemplate(alert, animated: true, completion: nil) + } + + private func getPageButtons(endIndex: Int, currentPage: Int, totalCount: Int) -> [CPBarButton] { + var barButtons: [CPBarButton] = [] + + guard let forwardImage = UIImage(systemName: "arrow.forward"), + let backwardImage = UIImage(systemName: "arrow.backward") else { return [] } + + if endIndex < totalCount { + barButtons.append(CPBarButton( + image: forwardImage, + handler: { _ in + self.changePage(to: .Next) + } + )) + } else { + barButtons + .append(CPBarButton( + image: UIImage(size: forwardImage.size, color: UIColor.clear), + handler: nil + )) + } + + if currentPage > 0 { + barButtons.append(CPBarButton( + image: backwardImage, + handler: { _ in + self.changePage(to: .Previous) + } + )) + } else { + barButtons + .append(CPBarButton( + image: UIImage(size: backwardImage.size, color: UIColor.clear), + handler: nil + )) + } + + return barButtons + } + + private func changePage(to: GridPage) { + switch to { + case .Next: + currentPage += 1 + case .Previous: + currentPage -= 1 + } + updateListItems() + } + + func templateWillDisappear(template: CPTemplate) { + if self.template == template { + entitiesSubscriptionToken?.cancel() + } + } + + func templateWillAppear(template: CPTemplate) {} +}