diff --git a/NextcloudTalk.xcodeproj/project.pbxproj b/NextcloudTalk.xcodeproj/project.pbxproj index fc892eb4d..a65195e5a 100644 --- a/NextcloudTalk.xcodeproj/project.pbxproj +++ b/NextcloudTalk.xcodeproj/project.pbxproj @@ -161,6 +161,8 @@ 847EFC7236336B67A1A89358 /* libPods-BroadcastUploadExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A3D305FCD7BF7E727A62F35 /* libPods-BroadcastUploadExtension.a */; }; 8789AE73BFCAA413B43319C0 /* libPods-ShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 684807120F4439797973DF73 /* libPods-ShareExtension.a */; }; 9993261EDAC77481FF4EF58A /* libPods-NextcloudTalk.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F7C31E9D74F550EAF89931B /* libPods-NextcloudTalk.a */; }; + FEF2798C303AF9E500E7039D /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1FA93D8B2D70FB3400DF6CDF /* Intents.framework */; }; + FEF27993303AF9E500E7039D /* TalkCallIntents.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = FEF2798B303AF9E500E7039D /* TalkCallIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -206,6 +208,13 @@ remoteGlobalIDString = 2CC0014E24A1F0E900A20167; remoteInfo = NotificationServiceExtension; }; + FEF27991303AF9E500E7039D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2C0574751EDD9E8E00D9E7F2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = FEF2798A303AF9E500E7039D; + remoteInfo = TalkCallIntents; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -278,6 +287,7 @@ files = ( 2C62AFAE24C08845007E460A /* ShareExtension.appex in Embed App Extensions */, 2CC0015724A1F0E900A20167 /* NotificationServiceExtension.appex in Embed App Extensions */, + FEF27993303AF9E500E7039D /* TalkCallIntents.appex in Embed App Extensions */, 1FF2FD722AB99CCB000C9905 /* BroadcastUploadExtension.appex in Embed App Extensions */, ); name = "Embed App Extensions"; @@ -464,6 +474,7 @@ B7874918820589BF8FD69BED /* Pods-NextcloudTalkTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NextcloudTalkTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-NextcloudTalkTests/Pods-NextcloudTalkTests.release.xcconfig"; sourceTree = ""; }; D6DF51D976DC0F681FF83F7B /* Pods-NotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; D86091EC1125C3057B9A299B /* Pods-NotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationServiceExtension.release.xcconfig"; path = "Pods/Target Support Files/Pods-NotificationServiceExtension/Pods-NotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; + FEF2798B303AF9E500E7039D /* TalkCallIntents.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TalkCallIntents.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -980,6 +991,13 @@ ); target = 2C05747C1EDD9E8E00D9E7F2 /* NextcloudTalk */; }; + FEF27997303AF9E500E7039D /* Exceptions for "TalkCallIntents" folder in "TalkCallIntents" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = FEF2798A303AF9E500E7039D /* TalkCallIntents */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -1177,6 +1195,19 @@ path = TalkIntents; sourceTree = ""; }; + FEF26F573039D9C400E7039D /* Carplay */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Carplay; + sourceTree = ""; + }; + FEF2798D303AF9E500E7039D /* TalkCallIntents */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + FEF27997303AF9E500E7039D /* Exceptions for "TalkCallIntents" folder in "TalkCallIntents" target */, + ); + path = TalkCallIntents; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1313,6 +1344,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FEF27988303AF9E500E7039D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FEF2798C303AF9E500E7039D /* Intents.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -1324,6 +1363,7 @@ 1F2058A32CEBE4EA00AAA673 /* BroadcastUploadExtension */, 1F8977F52DB0F76100E8114D /* NextcloudTalkTests */, 1FA93D9D2D70FCC200DF6CDF /* TalkIntents */, + FEF2798D303AF9E500E7039D /* TalkCallIntents */, 2C05747E1EDD9E8E00D9E7F2 /* Products */, 2C05749C1EDDA01700D9E7F2 /* ThirdParty */, 2C05747F1EDD9E8E00D9E7F2 /* NextcloudTalk */, @@ -1343,6 +1383,7 @@ 1FF2FD5B2AB99CCB000C9905 /* BroadcastUploadExtension.appex */, 1F6D8C302B2E3756004376B8 /* NextcloudTalkTests.xctest */, 1FA93D9C2D70FCC200DF6CDF /* TalkIntents.appex */, + FEF2798B303AF9E500E7039D /* TalkCallIntents.appex */, ); name = Products; sourceTree = ""; @@ -1350,6 +1391,7 @@ 2C05747F1EDD9E8E00D9E7F2 /* NextcloudTalk */ = { isa = PBXGroup; children = ( + FEF26F573039D9C400E7039D /* Carplay */, 1F9629252E8E6A2100EC9BEE /* Calls */, 1F9629262E8E6A6800EC9BEE /* Chat */, 1F9629302E8E6B3D00EC9BEE /* Contacts */, @@ -1670,6 +1712,7 @@ 2C62AFAD24C08845007E460A /* PBXTargetDependency */, 1FF2FD712AB99CCB000C9905 /* PBXTargetDependency */, 1FA93DA42D70FCC200DF6CDF /* PBXTargetDependency */, + FEF27992303AF9E500E7039D /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 1F2058832CEBE4E700AAA673 /* ShareExtension */, @@ -1692,6 +1735,7 @@ 1F96297A2E8E6D1200EC9BEE /* User Interface */, 1F9629822E8E6D5400EC9BEE /* WebRTC */, 1F9629882E8E6E2900EC9BEE /* Sounds */, + FEF26F573039D9C400E7039D /* Carplay */, ); name = NextcloudTalk; packageProductDependencies = ( @@ -1798,13 +1842,35 @@ productReference = 2CC0014F24A1F0E900A20167 /* NotificationServiceExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + FEF2798A303AF9E500E7039D /* TalkCallIntents */ = { + isa = PBXNativeTarget; + buildConfigurationList = FEF27994303AF9E500E7039D /* Build configuration list for PBXNativeTarget "TalkCallIntents" */; + buildPhases = ( + FEF27987303AF9E500E7039D /* Sources */, + FEF27988303AF9E500E7039D /* Frameworks */, + FEF27989303AF9E500E7039D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + FEF2798D303AF9E500E7039D /* TalkCallIntents */, + ); + name = TalkCallIntents; + packageProductDependencies = ( + ); + productName = TalkCallIntents; + productReference = FEF2798B303AF9E500E7039D /* TalkCallIntents.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 2C0574751EDD9E8E00D9E7F2 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1620; + LastSwiftUpdateCheck = 2650; LastUpgradeCheck = 1320; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -1852,6 +1918,9 @@ LastSwiftMigration = 1220; ProvisioningStyle = Automatic; }; + FEF2798A303AF9E500E7039D = { + CreatedOnToolsVersion = 26.5; + }; }; }; buildConfigurationList = 2C0574781EDD9E8E00D9E7F2 /* Build configuration list for PBXProject "NextcloudTalk" */; @@ -1924,6 +1993,7 @@ 1FD8AD892A3A162100787C16 /* NextcloudTalkUITests */, 1F6D8C2F2B2E3756004376B8 /* NextcloudTalkTests */, 1FA93D9B2D70FCC200DF6CDF /* TalkIntents */, + FEF2798A303AF9E500E7039D /* TalkCallIntents */, ); }; /* End PBXProject section */ @@ -2000,6 +2070,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FEF27989303AF9E500E7039D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2299,6 +2376,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FEF27987303AF9E500E7039D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2332,6 +2416,11 @@ target = 2CC0014E24A1F0E900A20167 /* NotificationServiceExtension */; targetProxy = 2CC0015524A1F0E900A20167 /* PBXContainerItemProxy */; }; + FEF27992303AF9E500E7039D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FEF2798A303AF9E500E7039D /* TalkCallIntents */; + targetProxy = FEF27991303AF9E500E7039D /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2560,6 +2649,7 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TalkIntents/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -2602,6 +2692,7 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TalkIntents/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -3296,6 +3387,87 @@ }; name = Release; }; + FEF27995303AF9E500E7039D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NKUJUXUJ3B; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TalkCallIntents/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TalkCallIntents; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.nextcloud.Talk.TalkCallIntents; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FEF27996303AF9E500E7039D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "iPhone Distribution: Nextcloud GmbH (NKUJUXUJ3B)"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NKUJUXUJ3B; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TalkCallIntents/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = TalkCallIntents; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.nextcloud.Talk.TalkCallIntents; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -3371,6 +3543,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FEF27994303AF9E500E7039D /* Build configuration list for PBXNativeTarget "TalkCallIntents" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FEF27995303AF9E500E7039D /* Debug */, + FEF27996303AF9E500E7039D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/NextcloudTalk/Carplay/CarPlayConversationProvider.swift b/NextcloudTalk/Carplay/CarPlayConversationProvider.swift new file mode 100644 index 000000000..8840da76f --- /dev/null +++ b/NextcloudTalk/Carplay/CarPlayConversationProvider.swift @@ -0,0 +1,42 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +final class CarPlayConversationProvider { + + static let shared = CarPlayConversationProvider() + + private init() {} + + func conversations() -> [NCRoom] { + let account = NCDatabaseManager.sharedInstance().activeAccount() + + let rooms = NCDatabaseManager.sharedInstance().roomsForAccountId( + account.accountId, + withRealm: nil + ) + + return rooms + .filter { room in + room.isVisible && + !room.isArchived && + room.supportsCalling && + room.userCanStartCall + } + .sorted { first, second in + first.lastActivity > second.lastActivity + } + } + + func room(withToken token: String) -> NCRoom? { + let account = NCDatabaseManager.sharedInstance().activeAccount() + + return NCDatabaseManager.sharedInstance().room( + withToken: token, + forAccountId: account.accountId + ) + } +} diff --git a/NextcloudTalk/Carplay/CarPlayManager.swift b/NextcloudTalk/Carplay/CarPlayManager.swift new file mode 100644 index 000000000..b8fda6110 --- /dev/null +++ b/NextcloudTalk/Carplay/CarPlayManager.swift @@ -0,0 +1,256 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import CarPlay +import UIKit + +@available(iOS 14.0, *) +@MainActor +final class CarPlayManager { + + static let shared = CarPlayManager() + + // MARK: - CarPlay + + private weak var interfaceController: CPInterfaceController? + + // MARK: - Init + + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(roomsDidUpdate), + name: .NCRoomsManagerDidUpdateRooms, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(activeAccountDidChange), + name: .NCSettingsControllerDidChangeActiveAccount, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Connection + + func connect(_ interfaceController: CPInterfaceController) { + self.interfaceController = interfaceController + + refreshRootTemplate() + + NCRoomsManager.shared.updateRooms( + updatingUserStatus: false, + onlyLastModified: false + ) + } + + func disconnect() { + interfaceController = nil + } + + // MARK: - Root + + private func refreshRootTemplate() { + guard let interfaceController else { + return + } + + let template = makeConversationsTemplate() + + interfaceController.setRootTemplate( + template, + animated: false, + completion: nil + ) + } + + // MARK: - Conversations + + private func makeConversationsTemplate() -> CPListTemplate { + let rooms = CarPlayConversationProvider.shared.conversations() + + if rooms.isEmpty { + let emptyItem = CPListItem( + text: NSLocalizedString( + "No conversations", + comment: "" + ), + detailText: nil + ) + + emptyItem.isEnabled = false + + return CPListTemplate( + title: NSLocalizedString( + "Talk", + comment: "" + ), + sections: [ + CPListSection(items: [emptyItem]) + ] + ) + } + + let items: [CPListItem] = rooms + .prefix(100) + .map { room in + makeConversationItem(room) + } + + let section = CPListSection( + items: items + ) + + let template = CPListTemplate( + title: NSLocalizedString( + "Conversations", + comment: "" + ), + sections: [section] + ) + + return template + } + + private func makeConversationItem( + _ room: NCRoom + ) -> CPListItem { + + let item = CPListItem( + text: room.displayName, + detailText: subtitle(for: room), + image: UIImage( + systemName: "person.crop.circle" + ) + ) + + item.handler = { [weak self] _, completion in + self?.showConversation(room) + completion() + } + + return item + } + + private func subtitle( + for room: NCRoom + ) -> String? { + + if room.hasCall { + return NSLocalizedString( + "Call in progress", + comment: "" + ) + } + + if room.unreadMessages > 0 { + return String.localizedStringWithFormat( + NSLocalizedString( + "%ld unread messages", + comment: "" + ), + room.unreadMessages + ) + } + + return nil + } + + // MARK: - Conversation details + + private func showConversation( + _ room: NCRoom + ) { + guard let interfaceController else { + return + } + + let contactImage = + UIImage(systemName: "person.crop.circle") + ?? UIImage() + + let contact = CPContact( + name: room.displayName, + image: contactImage + ) + + contact.subtitle = NSLocalizedString( + "Nextcloud Talk", + comment: "" + ) + + let callButton = CPContactCallButton { [weak self] _ in + self?.startAudioCall(room) + } + + callButton.title = NSLocalizedString( + "Call", + comment: "" + ) + + contact.actions = [ + callButton + ] + + let template = CPContactTemplate( + contact: contact + ) + + interfaceController.pushTemplate( + template, + animated: true, + completion: nil + ) + } + + // MARK: - Call + + private func startAudioCall( + _ room: NCRoom + ) { + guard + room.supportsCalling, + room.userCanStartCall, + let token = room.token, + !token.isEmpty + else { + return + } + + let account = + NCDatabaseManager.sharedInstance().activeAccount() + + CallKitManager.shared.startCall( + token, + withVideoEnabled: false, + andDisplayName: room.displayName, + asInitiator: true, + silently: false, + recordingConsent: false, + withAccountId: account.accountId + ) + } + + // MARK: - Notifications + + @objc + private func roomsDidUpdate() { + DispatchQueue.main.async { [weak self] in + self?.refreshRootTemplate() + } + } + + @objc + private func activeAccountDidChange() { + DispatchQueue.main.async { [weak self] in + self?.refreshRootTemplate() + } + } +} diff --git a/NextcloudTalk/Carplay/CarPlaySceneDelegate.swift b/NextcloudTalk/Carplay/CarPlaySceneDelegate.swift new file mode 100644 index 000000000..a5790ea7e --- /dev/null +++ b/NextcloudTalk/Carplay/CarPlaySceneDelegate.swift @@ -0,0 +1,34 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import CarPlay +import UIKit + +@available(iOS 14.0, *) +final class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate { + + override init() { + super.init() + print("### CARPLAY SCENE DELEGATE CREATED ###") + } + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didConnect interfaceController: CPInterfaceController + ) { + print("### CARPLAY CONNECTED ###") + + CarPlayManager.shared.connect(interfaceController) + } + + func templateApplicationScene( + _ templateApplicationScene: CPTemplateApplicationScene, + didDisconnectInterfaceController interfaceController: CPInterfaceController + ) { + print("### CARPLAY DISCONNECTED ###") + + CarPlayManager.shared.disconnect() + } +} diff --git a/NextcloudTalk/Info.plist b/NextcloudTalk/Info.plist index 6c8ac4641..242503d60 100644 --- a/NextcloudTalk/Info.plist +++ b/NextcloudTalk/Info.plist @@ -39,6 +39,8 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + LSApplicationQueriesSchemes nextcloud @@ -60,31 +62,46 @@ Microphone is used in voice and video calls NSPhotoLibraryUsageDescription Photo Library access is needed to share pictures and videos from your device + NSSpeechRecognitionUsageDescription + Speech Recognition access is needed to transcribe a voice message to text using on-device recognition NSUserActivityTypes INSendMessageIntent - NSSpeechRecognitionUsageDescription - Speech Recognition access is needed to transcribe a voice message to text using on-device recognition UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - + + UIApplicationSupportsMultipleScenes + + + UISceneConfigurations + + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + NextcloudTalk.SceneDelegate + UISceneStoryboardFile + Main + + + + CPTemplateApplicationSceneSessionRoleApplication + + + UISceneClassName + CPTemplateApplicationScene + UISceneConfigurationName + CarPlay + UISceneDelegateClassName + NextcloudTalk.CarPlaySceneDelegate + + + + + UIBackgroundModes audio @@ -130,7 +147,5 @@ UIViewControllerBasedStatusBarAppearance - ITSAppUsesNonExemptEncryption - diff --git a/NextcloudTalk/NextcloudTalk.entitlements b/NextcloudTalk/NextcloudTalk.entitlements index 9660871d5..7df708eac 100644 --- a/NextcloudTalk/NextcloudTalk.entitlements +++ b/NextcloudTalk/NextcloudTalk.entitlements @@ -4,6 +4,8 @@ aps-environment development + com.apple.developer.carplay-communication + com.apple.developer.usernotifications.communication com.apple.security.application-groups diff --git a/TalkCallIntents/Info.plist b/TalkCallIntents/Info.plist new file mode 100644 index 000000000..6c5b81413 --- /dev/null +++ b/TalkCallIntents/Info.plist @@ -0,0 +1,22 @@ + + + + + NSExtension + + NSExtensionAttributes + + IntentsRestrictedWhileLocked + + IntentsSupported + + INStartCallIntent + + + NSExtensionPointIdentifier + com.apple.intents-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).IntentHandler + + + diff --git a/TalkCallIntents/IntentHandler.swift b/TalkCallIntents/IntentHandler.swift new file mode 100644 index 000000000..e18083c94 --- /dev/null +++ b/TalkCallIntents/IntentHandler.swift @@ -0,0 +1,324 @@ +// +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation +import Intents + +final class IntentHandler: INExtension { + + // MARK: - Intent routing + + override func handler(for intent: INIntent) -> Any { + switch intent { + case is INStartCallIntent: + return StartCallIntentHandler() + + default: + assertionFailure("Unhandled Siri intent: \(type(of: intent))") + return self + } + } +} + +// MARK: - Start Call + +final class StartCallIntentHandler: NSObject, INStartCallIntentHandling { + + // MARK: Configuration + + private enum Constants { + static let userActivityType = "com.nextcloud.Talk.startCall" + static let activityContactsKey = "contacts" + static let activityCallCapabilityKey = "callCapability" + static let activityDestinationTypeKey = "destinationType" + static let activityCallRecordIdentifierKey = "callRecordIdentifier" + } + + // MARK: - Resolve call capability + + func resolveCallCapability( + for intent: INStartCallIntent, + with completion: @escaping (INStartCallCallCapabilityResolutionResult) -> Void + ) { + + switch intent.callCapability { + case .audioCall: + completion(.success(with: .audioCall)) + + case .videoCall: + completion(.success(with: .audioCall)) + + case .unknown: + completion(.success(with: .audioCall)) + + @unknown default: + completion(.success(with: .audioCall)) + } + } + + // MARK: - Resolve destination + + func resolveDestinationType( + for intent: INStartCallIntent, + with completion: @escaping (INCallDestinationTypeResolutionResult) -> Void + ) { + + switch intent.destinationType { + case .normal: + completion(.success(with: .normal)) + case .emergency: + completion(.unsupported()) + case .voicemail: + completion(.unsupported()) + case .redial: + completion(.success(with: .redial)) + case .unknown: + completion(.success(with: .normal)) + @unknown default: + completion(.unsupported()) + } + } + + // MARK: - Resolve contacts + + func resolveContacts( + for intent: INStartCallIntent, + with completion: @escaping ([INPersonResolutionResult]) -> Void + ) { + guard let contacts = intent.contacts, !contacts.isEmpty else { + completion([ + .needsValue() + ]) + return + } + + let results = contacts.map { person -> INPersonResolutionResult in + .success(with: person) + } + + completion(results) + } + + // MARK: - Resolve call-back record + + func resolveCallRecordToCallBack( + for intent: INStartCallIntent, + with completion: @escaping (INCallRecordResolutionResult) -> Void + ) { + guard let callRecord = intent.callRecordToCallBack else { + /* + * This parameter for callbacks. A regular call with Siri "Call Paul with Talk" does not use it + */ + completion(.notRequired()) + return + } + + completion(.success(with: callRecord)) + } + + // MARK: - Confirm + + func confirm( + intent: INStartCallIntent, + completion: @escaping (INStartCallIntentResponse) -> Void + ) { + + let hasContacts = !(intent.contacts?.isEmpty ?? true) + let hasCallRecord = intent.callRecordToCallBack != nil + + guard hasContacts || hasCallRecord else { + completion( + INStartCallIntentResponse( + code: .failureContactNotSupportedByApp, + userActivity: nil + ) + ) + return + } + + completion( + INStartCallIntentResponse( + code: .ready, + userActivity: nil + ) + ) + } + + // MARK: - Handle + + func handle( + intent: INStartCallIntent, + completion: @escaping (INStartCallIntentResponse) -> Void + ) { + + guard + !(intent.contacts?.isEmpty ?? true) + || intent.callRecordToCallBack != nil + else { + completion( + INStartCallIntentResponse( + code: .failureContactNotSupportedByApp, + userActivity: nil + ) + ) + return + } + + let activity = makeUserActivity(from: intent) + + completion( + INStartCallIntentResponse( + code: .continueInApp, + userActivity: activity + ) + ) + } + + // MARK: - User activity + + private func makeUserActivity( + from intent: INStartCallIntent + ) -> NSUserActivity { + + let activity = NSUserActivity( + activityType: Constants.userActivityType + ) + + activity.title = NSLocalizedString( + "Start a Talk call", + comment: "Siri activity used to start a Talk call" + ) + + activity.isEligibleForHandoff = false + activity.isEligibleForSearch = false + activity.isEligibleForPublicIndexing = false + + var userInfo: [String: Any] = [:] + + if let contacts = intent.contacts { + userInfo[Constants.activityContactsKey] = + contacts.compactMap(Self.serialisePerson) + } + + userInfo[Constants.activityCallCapabilityKey] = + serialiseCallCapability(intent.callCapability) + + userInfo[Constants.activityDestinationTypeKey] = + serialiseDestinationType(intent.destinationType) + + if let callRecord = intent.callRecordToCallBack { + userInfo[Constants.activityCallRecordIdentifierKey] = + callRecord.identifier + } + + activity.userInfo = userInfo + + return activity + } + + // MARK: - Serialisation + + private static func serialisePerson( + _ person: INPerson + ) -> [String: String]? { + + var result: [String: String] = [:] + + if let customIdentifier = person.customIdentifier, + !customIdentifier.isEmpty { + result["customIdentifier"] = customIdentifier + } + + let displayName = person.displayName + + if !displayName.isEmpty { + result["displayName"] = displayName + } + + if let contactIdentifier = person.contactIdentifier, + !contactIdentifier.isEmpty { + result["contactIdentifier"] = contactIdentifier + } + + if let handle = person.personHandle { + + if let value = handle.value, + !value.isEmpty { + result["handle"] = value + } + + result["handleType"] = + serialisePersonHandleType(handle.type) + } + + return result.isEmpty ? nil : result + } + + private static func serialisePersonHandleType( + _ type: INPersonHandleType + ) -> String { + + switch type { + + case .unknown: + return "unknown" + + case .emailAddress: + return "email" + + case .phoneNumber: + return "phone" + + @unknown default: + return "unknown" + } + } + + private func serialiseCallCapability( + _ capability: INCallCapability + ) -> String { + + switch capability { + + case .unknown: + return "unknown" + + case .audioCall: + return "audio" + + case .videoCall: + return "video" + + @unknown default: + return "unknown" + } + } + + private func serialiseDestinationType( + _ destination: INCallDestinationType + ) -> String { + + switch destination { + + case .unknown: + return "unknown" + + case .normal: + return "normal" + + case .emergency: + return "emergency" + + case .voicemail: + return "voicemail" + + case .redial: + return "redial" + + @unknown default: + return "unknown" + } + } +}