From 9a7f7750ade644f59dabe1e911bab0de468d7b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantalea=CC=83o?= Date: Fri, 22 Dec 2023 01:08:01 +0100 Subject: [PATCH] CarPlay Actions first iteration --- .../activate_special_entitlements.sh | 10 ++ Configuration/HomeAssistant.xcconfig | 2 + HomeAssistant.xcodeproj/project.pbxproj | 16 ++++ Sources/App/AppDelegate.swift | 12 ++- .../CarPlay/CPFavoriteActionsSection.swift | 95 +++++++++++++++++++ Sources/App/Resources/Info.plist | 11 +++ .../Resources/en.lproj/Localizable.strings | 4 +- Sources/App/Scenes/CarPlaySceneDelegate.swift | 44 +++++++++ Sources/App/Scenes/SceneActivity.swift | 10 +- Sources/Shared/API/HAAPI.swift | 1 + .../Shared/Resources/Swiftgen/Strings.swift | 9 ++ 11 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 Sources/App/CarPlay/CPFavoriteActionsSection.swift create mode 100644 Sources/App/Scenes/CarPlaySceneDelegate.swift diff --git a/Configuration/Entitlements/activate_special_entitlements.sh b/Configuration/Entitlements/activate_special_entitlements.sh index 330e5bf31..b3946024a 100755 --- a/Configuration/Entitlements/activate_special_entitlements.sh +++ b/Configuration/Entitlements/activate_special_entitlements.sh @@ -29,6 +29,16 @@ 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 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 0eeda9131..4addc1b85 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 2180cee30..031633806 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -518,6 +518,8 @@ 420B100C2B1D204400D383D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 420B100B2B1D204400D383D8 /* Assets.xcassets */; }; 424A7F462B188946008C8DF3 /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F452B188946008C8DF3 /* WidgetBackground.swift */; }; 424A7F482B188BF3008C8DF3 /* WidgetContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424A7F472B188BF3008C8DF3 /* WidgetContentMargin.swift */; }; + 424DD0572B3508F40057E456 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424DD0562B3508F40057E456 /* CarPlaySceneDelegate.swift */; }; + 424DD05A2B3509170057E456 /* CPFavoriteActionsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424DD0592B3509170057E456 /* CPFavoriteActionsSection.swift */; }; 426740A92B17391000C1DD73 /* Data+Hexadecimal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 426740A72B17390A00C1DD73 /* Data+Hexadecimal.swift */; }; 429C72202B28D0EC00BCD558 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429C721F2B28D0EC00BCD558 /* Haptics.swift */; }; 42CA28BB2B1028330093B31A /* SimulatorThreadClientService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CA28BA2B1028330093B31A /* SimulatorThreadClientService.swift */; }; @@ -1626,6 +1628,8 @@ 4242A2D12B2B5C9F00E9F001 /* es-MX */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "es-MX"; path = "es-MX.lproj/AppIntentVocabulary.plist"; sourceTree = ""; }; 424A7F452B188946008C8DF3 /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; 424A7F472B188BF3008C8DF3 /* WidgetContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetContentMargin.swift; sourceTree = ""; }; + 424DD0562B3508F40057E456 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; + 424DD0592B3509170057E456 /* CPFavoriteActionsSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CPFavoriteActionsSection.swift; sourceTree = ""; }; 426740A72B17390A00C1DD73 /* Data+Hexadecimal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Hexadecimal.swift"; sourceTree = ""; }; 42805A132B0226050095414C /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Base; path = Base.lproj/AppIntentVocabulary.plist; sourceTree = ""; }; 429C721F2B28D0EC00BCD558 /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; @@ -2906,6 +2910,7 @@ 11EFCDD424F5FA7E00314D85 /* Scenes */ = { isa = PBXGroup; children = ( + 424DD0562B3508F40057E456 /* CarPlaySceneDelegate.swift */, 11EFCDD524F5FA8D00314D85 /* WebViewSceneDelegate.swift */, 11EFCDD724F5FCBE00314D85 /* SettingsSceneDelegate.swift */, 11EFCDD924F5FE0600314D85 /* SceneActivity.swift */, @@ -3017,6 +3022,14 @@ name = Frameworks; sourceTree = ""; }; + 424DD0582B3509170057E456 /* CarPlay */ = { + isa = PBXGroup; + children = ( + 424DD0592B3509170057E456 /* CPFavoriteActionsSection.swift */, + ); + path = CarPlay; + sourceTree = ""; + }; 426740A42B17348700C1DD73 /* Assets */ = { isa = PBXGroup; children = ( @@ -3414,6 +3427,7 @@ children = ( 42CFCD682B1F956D00CCEF4A /* DropSupport */, B657A8E91CA646EB00121384 /* AppDelegate.swift */, + 424DD0582B3509170057E456 /* CarPlay */, D03D893720E0AF1B00D4F28D /* ClientEvents */, 11A183B22511BCF300CA326A /* LifecycleManager.swift */, 117EB13E2569AD3000049541 /* Notifications */, @@ -5611,6 +5625,7 @@ 11BD7B4D25B53D7F001826F0 /* AppMacBridgeStatusItemConfiguration.swift in Sources */, 11F55EED25D3B088003977AC /* NotificationDebugNotificationsViewController.swift in Sources */, B6022223226DBA3800E8DBFE /* OnboardingNavigationViewController.swift in Sources */, + 424DD0572B3508F40057E456 /* CarPlaySceneDelegate.swift in Sources */, 11DA6B4B27137A60008ADFAF /* InputAccessoryView.swift in Sources */, 11A71C6D24A4641600D9565F /* ZoneManagerEvent.swift in Sources */, 11F3D7512495434C00C05BBA /* SensorDetailViewController.swift in Sources */, @@ -5658,6 +5673,7 @@ 11EFCDD624F5FA8D00314D85 /* WebViewSceneDelegate.swift in Sources */, 1185DF94271FBA6100ED7D9A /* OnboardingAuthDetails.swift in Sources */, B6DA3C7122690B1F00DE811C /* NotificationSoundsViewController.swift in Sources */, + 424DD05A2B3509170057E456 /* CPFavoriteActionsSection.swift in Sources */, D0C88462211ED16300CCB501 /* OnboardingAuth.swift in Sources */, 11EFCDDC24F6065F00314D85 /* AboutSceneDelegate.swift in Sources */, B6D8A32A2271455300FA765D /* OnboardingErrorViewController.swift in Sources */, diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index a0ab616e5..161739413 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -201,10 +201,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 14.0, *), connectingSceneSession.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, shouldRestoreSecureApplicationState coder: NSCoder) -> Bool { diff --git a/Sources/App/CarPlay/CPFavoriteActionsSection.swift b/Sources/App/CarPlay/CPFavoriteActionsSection.swift new file mode 100644 index 000000000..e33c1f384 --- /dev/null +++ b/Sources/App/CarPlay/CPFavoriteActionsSection.swift @@ -0,0 +1,95 @@ +import CarPlay +import Foundation +import RealmSwift +import Shared + +@available(iOS 15.0, *) +final class CPFavoriteActionsSection { + private var noActionsView: CPInformationTemplate = { + CPInformationTemplate( + title: L10n.About.Logo.title, + layout: .leading, + items: [ + .init(title: L10n.CarPlay.NoActions.title, detail: nil), + ], + actions: [] + ) + }() + + func list(for actions: Results) -> CPTemplate { + if actions.isEmpty { + noActionsView + } else { + CPListTemplate( + title: L10n.SettingsDetails.Actions.title, sections: [ + section(actions: actions), + ] + ) + } + } + + private func section(actions: Results) -> CPListSection { + let items: [CPListItem] = actions.map { action in + let materialDesignIcon = MaterialDesignIcons(named: action.IconName) + .image(ofSize: CPListItem.maximumImageSize, color: UIColor(hex: action.IconColor)) + let croppedIcon = cropImageToSquare(image: materialDesignIcon)! + let carPlayIcon = carPlayImage(from: croppedIcon)! + let item = CPListItem( + text: action.Name, + detailText: action.Text, + image: carPlayIcon + ) + item.handler = { _, completion in + guard let server = Current.servers.server(for: action) else { + completion() + return + } + Current.api(for: server).HandleAction(actionID: action.ID, source: .CarPlay).pipe { result in + switch result { + case .fulfilled: + break + case let .rejected(error): + Current.Log.info(error) + } + completion() + } + } + return item + } + + return CPListSection(items: items) + } +} + +// MARK: - CarPlay Image Resize + +@available(iOS 15.0, *) +extension CPFavoriteActionsSection { + private func carPlayImage(from image: UIImage) -> UIImage? { + let imageAsset = UIImageAsset() + imageAsset.register(image, with: .current) + return imageAsset.image(with: .current) + } + + private func resizeImage(image: UIImage, newSize: CGSize) -> UIImage? { + let renderer = UIGraphicsImageRenderer(size: newSize) + let resizedImage = renderer.image { _ in + image.draw(in: CGRect(origin: .zero, size: newSize)) + } + return resizedImage + } + + private func cropImageToSquare(image: UIImage) -> UIImage? { + let originalSize = image.size + let squareSize = min(originalSize.width, originalSize.height) + let newSize = CGSize(width: squareSize, height: squareSize) + let origin = CGPoint(x: (originalSize.width - squareSize) / 2, y: (originalSize.height - squareSize) / 2) + + let renderer = UIGraphicsImageRenderer(size: newSize) + let croppedImage = renderer.image { _ in + image.draw(at: CGPoint(x: -origin.x, y: -origin.y)) + } + + return croppedImage + } +} diff --git a/Sources/App/Resources/Info.plist b/Sources/App/Resources/Info.plist index bb444b7b2..db99ce996 100644 --- a/Sources/App/Resources/Info.plist +++ b/Sources/App/Resources/Info.plist @@ -580,6 +580,17 @@ About + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneClassName + CPTemplateApplicationScene + UISceneConfigurationName + CarPlay + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).CarPlaySceneDelegate + + CFBundleVersion diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index c064bcb09..1902b6866 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"; @@ -784,4 +785,5 @@ 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.no_actions.title" = "Open iOS Companion App to create actions for CarPlay."; diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift new file mode 100644 index 000000000..70f6ea6b8 --- /dev/null +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -0,0 +1,44 @@ +import CarPlay +import Foundation +import Realm +import RealmSwift +import Shared + +@available(iOS 15.0, *) +final class CarPlaySceneDelegate: NSObject, CPTemplateApplicationSceneDelegate { + private var interfaceController: CPInterfaceController? + private var rootTemplate: CPTemplate? + private let realm = Current.realm() + private var actionsToken: RLMNotificationToken? + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController + ) { + self.interfaceController = interfaceController + + let actions = realm.objects(Action.self) + .sorted(byKeyPath: "Position") + .filter("Scene == nil") + + actionsToken = actions.observe { [weak self] _ in + self?.setActions(actions: actions) + } + + setActions(actions: actions) + } + + private func setActions(actions: Results) { + /* It's called 'tab' because the plan is to put this inside a tab bar + in the next iterations */ + let actionsTab = CPFavoriteActionsSection().list(for: actions) + + self.rootTemplate = actionsTab + guard let rootTemplate = rootTemplate else { return } + interfaceController?.setRootTemplate( + rootTemplate, + animated: true, + completion: nil + ) + } +} diff --git a/Sources/App/Scenes/SceneActivity.swift b/Sources/App/Scenes/SceneActivity.swift index 0528d7035..62e76277f 100644 --- a/Sources/App/Scenes/SceneActivity.swift +++ b/Sources/App/Scenes/SceneActivity.swift @@ -5,6 +5,7 @@ enum SceneActivity: CaseIterable { case webView case settings case about + case carPlay init(activityIdentifier: String) { self = Self.allCases.first(where: { $0.activityIdentifier == activityIdentifier }) ?? .webView @@ -23,6 +24,7 @@ enum SceneActivity: CaseIterable { case .settings: return "ha.settings" case .webView: return "ha.webview" case .about: return "ha.about" + case .carPlay: return "ha.carPlay" } } @@ -31,10 +33,16 @@ 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 .carPlay: + return .init(name: configurationName, sessionRole: .carTemplateApplication) + default: + return .init(name: configurationName, sessionRole: .windowApplication) + } } } diff --git a/Sources/Shared/API/HAAPI.swift b/Sources/Shared/API/HAAPI.swift index 2c3f72738..0f64287cd 100644 --- a/Sources/Shared/API/HAAPI.swift +++ b/Sources/Shared/API/HAAPI.swift @@ -563,6 +563,7 @@ public class HomeAssistantAPI { case Preview = "preview" case SiriShortcut = "siriShortcut" case URLHandler = "urlHandler" + case CarPlay = "carPlay" public var description: String { rawValue diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index cefb56263..bf24c4e4a 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 @@ -229,6 +231,13 @@ public enum L10n { } } + public enum CarPlay { + public enum NoActions { + /// Open iOS Companion App to create actions for CarPlay. + public static var title: String { return L10n.tr("Localizable", "carPlay.no_actions.title") } + } + } + public enum ClError { public enum Description { /// Deferred mode is not supported for the requested accuracy.