diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 2d62f70b3..fae6c0a92 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -65,7 +65,60 @@ class CoreContext: ObservableObject { do { try initialiseCore() } catch { - + + } + observeMDMNotifications() + } + + // MARK: - MDM notifications + + private func observeMDMNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(onMDMConfigurationApplied(_:)), name: MDMManager.configurationAppliedNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(onMDMConfigurationRemoved), name: MDMManager.configurationRemovedNotification, object: nil) + } + + @objc private func onMDMConfigurationApplied(_ notification: Notification) { + Log.info("[CoreContext] MDM configuration applied, refreshing configuration") + CoreContext.shared.performActionOnCoreQueueWhenCoreIsStarted { core in + self.handleConfigurationChanged(status: .Successful) + } + } + + @objc private func onMDMConfigurationRemoved() { + doOnCoreQueue { core in + Log.info("[CoreContext] MDM configuration removed, re-initializing app to default state") + var startCore = false + if core.globalState == .On { + core.stop() + startCore = true + } + AppServices.resetConfig() + self.mCore.config?.reload() + if startCore { + try?core.start() + } + self.handleConfigurationChanged(status: .Successful) + } + } + + /// Shared handler for configuration changes (both from core provisioning and MDM). + private func handleConfigurationChanged(status: ConfiguringState) { + let themeMainColor = AppServices.corePreferences.themeMainColor + SharedMainViewModel.shared.updateConfigChanges() + if status == .Successful { + var accountModels: [AccountModel] = [] + for account in self.mCore.accountList { + accountModels.append(AccountModel(account: account, core: self.mCore)) + } + DispatchQueue.main.async { + self.accounts = accountModels + if accountModels.isEmpty { + self.loggingInProgress = false + self.loggedIn = false + } + ThemeManager.shared.applyTheme(named: themeMainColor) + self.reloadID = UUID() + } } } @@ -143,8 +196,14 @@ class CoreContext: ObservableObject { Log.info("Found existing linphonerc file, skip copying of linphonerc-default configuration") } } - + + MDMManager.shared.loadXMLConfigFromMdm(config: AppServices.config) + self.mCore = try? Factory.Instance.createSharedCoreWithConfig(config: AppServices.config, systemContext: Unmanaged.passUnretained(coreQueue).toOpaque(), appGroupId: SharedMainViewModel.appGroupName, mainCore: true) + + MDMManager.shared.applyMdmConfigToCore(core: self.mCore) + self.startObservingMDMConfigurationUpdates() + self.mCore.callkitEnabled = true self.mCore.pushNotificationEnabled = true @@ -348,19 +407,7 @@ class CoreContext: ObservableObject { } }, onConfiguringStatus: { (_: Core, status: ConfiguringState, message: String) in Log.info("New configuration state is \(status) = \(message)\n") - let themeMainColor = AppServices.corePreferences.themeMainColor - SharedMainViewModel.shared.updateConfigChanges() - DispatchQueue.main.async { - if status == ConfiguringState.Successful { - var accountModels: [AccountModel] = [] - for account in self.mCore.accountList { - accountModels.append(AccountModel(account: account, core: self.mCore)) - } - self.accounts = accountModels - ThemeManager.shared.applyTheme(named: themeMainColor) - self.reloadID = UUID() - } - } + self.handleConfigurationChanged(status: status) }, onLogCollectionUploadStateChanged: { (_: Core, _: Core.LogCollectionUploadState, info: String) in if info.starts(with: "https") { DispatchQueue.main.async { @@ -563,6 +610,24 @@ class CoreContext: ObservableObject { } } } + + func startObservingMDMConfigurationUpdates() { + NotificationCenter.default.addObserver( + self, + selector: #selector(mdmConfigDidChange), + name: UserDefaults.didChangeNotification, + object: nil + ) + } + + @objc private func mdmConfigDidChange() { + guard MDMManager.shared.managedConfigChangedSinceLastCheck() else { return } + CoreContext.shared.doOnCoreQueue { core in + MDMManager.shared.applyMdmConfigToCore(core: core) + } + } + + } enum AppServices { @@ -589,8 +654,30 @@ enum AppServices { } return config } + + static func resetConfig() { + if let rcDir = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: SharedMainViewModel.appGroupName)? + .appendingPathComponent("Library/Preferences/linphone") { + let rcFileUrl = rcDir.appendingPathComponent("linphonerc") + do { + try FileManager.default.createDirectory(at: rcDir, withIntermediateDirectories: true) + if let pathToDefaultConfig = Bundle.main.path(forResource: "linphonerc-default", ofType: nil) { + if FileManager.default.fileExists(atPath: rcFileUrl.path) { + try FileManager.default.removeItem(at: rcFileUrl) + } + try FileManager.default.copyItem(at: URL(fileURLWithPath: pathToDefaultConfig), to: rcFileUrl) + Log.info("Successfully copied linphonerc-default configuration") + _config = nil + let _ = configIfAvailable + corePreferences = CorePreferences(config: config) + } + } catch let error { + Log.error("Failed to copy default linphonerc file: \(error.localizedDescription)") + } + } + } - static let corePreferences = CorePreferences(config: config) + static var corePreferences = CorePreferences(config: config) } // swiftlint:enable line_length diff --git a/Linphone/LinphoneApp.swift b/Linphone/LinphoneApp.swift index 4dfdb137c..5314de6ac 100644 --- a/Linphone/LinphoneApp.swift +++ b/Linphone/LinphoneApp.swift @@ -63,7 +63,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Set up notifications UNUserNotificationCenter.current().delegate = self - + return true } @@ -159,6 +159,9 @@ struct LinphoneApp: App { private let voipRegistry = PKPushRegistry(queue: coreQueue) init() { +#if DEBUG + LinphoneApp.applyUITestMDMConfigIfNeeded() +#endif if !configAvailable { voipRegistry.delegate = earlyPushDelegate voipRegistry.desiredPushTypes = [.voIP] @@ -168,6 +171,26 @@ struct LinphoneApp: App { } } +#if DEBUG + /// UI-test hook: reads `UITEST_MDM_CONFIG` (JSON) or `UITEST_MDM_CONFIG_CLEAR=1` from + /// the launch environment and writes it to the managed config key before MDMManager runs. + /// Only active in DEBUG builds. + private static func applyUITestMDMConfigIfNeeded() { + let env = ProcessInfo.processInfo.environment + let key = "com.apple.configuration.managed" + if env["UITEST_MDM_CONFIG_CLEAR"] == "1" { + UserDefaults.standard.removeObject(forKey: key) + return + } + guard let json = env["UITEST_MDM_CONFIG"], + let data = json.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return + } + UserDefaults.standard.set(dict, forKey: key) + } +#endif + var body: some Scene { WindowGroup { if configAvailable { diff --git a/Linphone/SplashScreen.swift b/Linphone/SplashScreen.swift index ee57021e3..40f495214 100644 --- a/Linphone/SplashScreen.swift +++ b/Linphone/SplashScreen.swift @@ -35,7 +35,8 @@ struct SplashScreen: View { .foregroundColor(ThemeManager.shared.currentTheme.main500) if showSpinner { - ProgressView() + PopupLoadingView() + .background(.black.opacity(0.65)) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift index fe29dea76..a29fbf809 100644 --- a/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift +++ b/Linphone/UI/Assistant/Fragments/PermissionsFragment.swift @@ -180,6 +180,7 @@ struct PermissionsFragment: View { ) .frame(maxWidth: SharedMainViewModel.shared.maxWidth) .padding(.horizontal) + .accessibilityIdentifier("permissions_skip_button") Button { permissionManager.getPermissions() diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index a9feaf894..a9b04e7e0 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -370,7 +370,7 @@ struct ContentView: View { Button(action: { resetFilter() - + sharedMainViewModel.changeIndexView(indexViewInt: 1) sharedMainViewModel.displayedFriend = nil sharedMainViewModel.displayedConversation = nil @@ -395,6 +395,7 @@ struct ContentView: View { } }) .padding(.top) + .accessibilityIdentifier("bottom_bar_calls_button") } Spacer() @@ -435,7 +436,7 @@ struct ContentView: View { .resizable() .foregroundStyle(sharedMainViewModel.indexView == 2 ? Color.orangeMain500 : Color.grayMain2c600) .frame(width: 25, height: 25) - + if sharedMainViewModel.indexView == 2 { Text("bottom_navigation_conversations_label") .default_text_style_700(styleSize: 10) @@ -446,6 +447,7 @@ struct ContentView: View { } }) .padding(.top) + .accessibilityIdentifier("bottom_bar_chat_button") } Spacer() diff --git a/Linphone/UI/Welcome/WelcomeView.swift b/Linphone/UI/Welcome/WelcomeView.swift index 31433f634..a62efe3e0 100644 --- a/Linphone/UI/Welcome/WelcomeView.swift +++ b/Linphone/UI/Welcome/WelcomeView.swift @@ -55,7 +55,7 @@ struct WelcomeView: View { Text("welcome_carousel_skip") .underline() .default_text_style_600(styleSize: 15) - + }) .padding(.top, -35) .padding(.trailing, 20) @@ -64,6 +64,7 @@ struct WelcomeView: View { self.index = 2 } ) + .accessibilityIdentifier("welcome_skip_button") Text("welcome_page_title") .default_text_style_800(styleSize: 35) .padding(.trailing, 100) diff --git a/Linphone/Utils/MDMManager.swift b/Linphone/Utils/MDMManager.swift new file mode 100644 index 000000000..d0360942c --- /dev/null +++ b/Linphone/Utils/MDMManager.swift @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import CryptoKit +import linphonesw + +class MDMManager { + + static let shared = MDMManager() + + static let configurationRemovedNotification = Notification.Name("MDMManager.configurationRemoved") + static let configurationAppliedNotification = Notification.Name("MDMManager.configurationApplied") + + private static let hasMDMConfigKey = "MDMManager.hasMDMConfig" + private static let lastXMLConfigSHA256Key = "MDMManager.lastXMLConfigSHA256" + private static let lastCoreConfigSHA256Key = "MDMManager.lastCoreConfigSHA256" + + private var hasMDMConfig: Bool { + get { UserDefaults.standard.bool(forKey: MDMManager.hasMDMConfigKey) } + set { + guard UserDefaults.standard.bool(forKey: MDMManager.hasMDMConfigKey) != newValue else { return } + UserDefaults.standard.set(newValue, forKey: MDMManager.hasMDMConfigKey) + } + } + + private var lastXMLConfigSHA256: String? { + get { UserDefaults.standard.string(forKey: MDMManager.lastXMLConfigSHA256Key) } + set { + guard UserDefaults.standard.string(forKey: MDMManager.lastXMLConfigSHA256Key) != newValue else { return } + UserDefaults.standard.set(newValue, forKey: MDMManager.lastXMLConfigSHA256Key) + } + } + + private var lastCoreConfigSHA256: String? { + get { UserDefaults.standard.string(forKey: MDMManager.lastCoreConfigSHA256Key) } + set { + guard UserDefaults.standard.string(forKey: MDMManager.lastCoreConfigSHA256Key) != newValue else { return } + UserDefaults.standard.set(newValue, forKey: MDMManager.lastCoreConfigSHA256Key) + } + } + + private var isApplyingConfig = false + + private var lastSeenManagedConfigSignature: String? + + func managedConfigChangedSinceLastCheck() -> Bool { + let signature: String + if let mdmConfig = UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed") { + signature = sha256Hash(of: mdmConfig) + } else { + signature = "" + } + if signature == lastSeenManagedConfigSignature { return false } + lastSeenManagedConfigSignature = signature + return true + } + + func loadXMLConfigFromMdm(config: Config) { + guard let mdmConfig = UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed"), + let xmlConfig = mdmConfig["xml-config"] as? String else { + lastXMLConfigSHA256 = nil + return + } + + let hash = sha256Hash(of: ["xml-config": xmlConfig]) + if hash == lastXMLConfigSHA256 { + Log.info("[MDMManager] xml-config unchanged (SHA256: \(hash)), skipping") + return + } + lastXMLConfigSHA256 = hash + + do { + try config.loadFromXmlString(buffer: xmlConfig) + Log.info("[MDMManager] xml-config applied (\(xmlConfig.count) chars)") + } catch let error { + Log.error("[MDMManager] Failed loading xml-config: error = \(error) xml = \(xmlConfig)") + } + } + + func applyMdmConfigToCore(core: Core) { + guard !isApplyingConfig else { return } + isApplyingConfig = true + defer { + isApplyingConfig = false + _ = managedConfigChangedSinceLastCheck() + } + + loadXMLConfigFromMdm(config: core.config!) + + guard let mdmConfig = UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed") else { + if hasMDMConfig { + Log.info("[MDMManager] Managed configuration was removed") + lastXMLConfigSHA256 = nil + lastCoreConfigSHA256 = nil + hasMDMConfig = false + handleConfigurationRemoved() + } else { + Log.info("[MDMManager] Managed configuration is empty") + } + return + } + + hasMDMConfig = true + + let subset: [String: Any] = [ + "root-ca": mdmConfig["root-ca"] ?? "", + "config-uri": mdmConfig["config-uri"] ?? "" + ] + let hash = sha256Hash(of: subset) + if hash == lastCoreConfigSHA256 { + Log.info("[MDMManager] root-ca/config-uri unchanged (SHA256: \(hash)), skipping") + NotificationCenter.default.post(name: MDMManager.configurationAppliedNotification, object: self, userInfo: ["config": mdmConfig]) + return + } + lastCoreConfigSHA256 = hash + + let currentProvisioningUri = core.provisioningUri + + if let rootCa = mdmConfig["root-ca"] as? String { + core.rootCaData = rootCa + Log.info("[MDMManager] root-ca applied (\(rootCa.count) chars)") + } + if let configUri = mdmConfig["config-uri"] as? String { + do { + if configUri != currentProvisioningUri { + try core.setProvisioninguri(newValue: configUri) + Log.info("[MDMManager] config-uri applied \(configUri)") + } + } catch let error { + Log.error("[MDMManager] Failed setting provisioning URI: error = \(error) configUri = \(configUri)") + } + } + + if core.globalState == .On { + do { + core.stop() + try core.start() + } catch let error { + Log.error("[MDMManager] Failed restarting core: error = \(error)") + } + } + + NotificationCenter.default.post(name: MDMManager.configurationAppliedNotification, object: self, userInfo: ["config": mdmConfig]) + } + + private func handleConfigurationRemoved() { + Log.info("[MDMManager] handleConfigurationRemoved - posting notification") + NotificationCenter.default.post(name: MDMManager.configurationRemovedNotification, object: self) + } + + private func sha256Hash(of dict: [String: Any]) -> String { + let description = dict.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: "&") + let digest = SHA256.hash(data: Data(description.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/LinphoneApp.xcodeproj/project.pbxproj b/LinphoneApp.xcodeproj/project.pbxproj index dc6069089..daa1b8f28 100644 --- a/LinphoneApp.xcodeproj/project.pbxproj +++ b/LinphoneApp.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ C642277C2E8E4D900094FEDC /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C642277A2E8E4AC50094FEDC /* ThemeManager.swift */; }; C642277D2E8E4E2B0094FEDC /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = D717071D2AC5922E0037746F /* ColorExtension.swift */; }; C65270F82F879D2700FF248C /* EarlyPushkitDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65270F72F879D2700FF248C /* EarlyPushkitDelegate.swift */; }; + C65271062F87D3E600FF248C /* MDMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65271052F87D3E600FF248C /* MDMManager.swift */; }; C67586AE2C09F23C002E77BF /* URLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586AD2C09F23C002E77BF /* URLExtension.swift */; }; C67586B02C09F247002E77BF /* URIHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586AF2C09F247002E77BF /* URIHandler.swift */; }; C67586B52C09F617002E77BF /* SingleSignOnManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67586B22C09F617002E77BF /* SingleSignOnManager.swift */; }; @@ -240,6 +241,20 @@ remoteGlobalIDString = 660AAF7A2B839271004C0FA6; remoteInfo = msgNotificationService; }; + C65271122F88D1CA00FF248C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D719ABAB2ABC67BF00B41C10 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D719ABB22ABC67BF00B41C10; + remoteInfo = LinphoneApp; + }; + C6E408B32F8E1A33003E0F8C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D719ABAB2ABC67BF00B41C10 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D719ABB22ABC67BF00B41C10; + remoteInfo = LinphoneApp; + }; D7458F372E0BDCF4000C957A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D719ABAB2ABC67BF00B41C10 /* Project object */; @@ -309,6 +324,8 @@ C62817332C1C7C7400DBA646 /* HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpView.swift; sourceTree = ""; }; C642277A2E8E4AC50094FEDC /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = ""; }; C65270F72F879D2700FF248C /* EarlyPushkitDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyPushkitDelegate.swift; sourceTree = ""; }; + C65271052F87D3E600FF248C /* MDMManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDMManager.swift; sourceTree = ""; }; + C652710C2F88D1CA00FF248C /* LinphoneAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LinphoneAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C67586AD2C09F23C002E77BF /* URLExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLExtension.swift; sourceTree = ""; }; C67586AF2C09F247002E77BF /* URIHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URIHandler.swift; sourceTree = ""; }; C67586B22C09F617002E77BF /* SingleSignOnManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleSignOnManager.swift; sourceTree = ""; }; @@ -318,6 +335,7 @@ C6A5A9472C10B6A30070FEA4 /* AuthState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthState.swift; sourceTree = ""; }; C6DC4E3C2C199C4E009096FD /* BundleExtenion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtenion.swift; sourceTree = ""; }; C6DC4E3E2C19C289009096FD /* SideMenuEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SideMenuEntry.swift; sourceTree = ""; }; + C6E408AF2F8E1A33003E0F8C /* LinphoneAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LinphoneAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D703F7072DC8C5FF005B8F75 /* FilePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePicker.swift; sourceTree = ""; }; D706BA812ADD72D100278F45 /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationModel.swift; sourceTree = ""; }; @@ -537,6 +555,8 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + C652710D2F88D1CA00FF248C /* LinphoneAppUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LinphoneAppUITests; sourceTree = ""; }; + C6E408B02F8E1A33003E0F8C /* LinphoneAppTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LinphoneAppTests; sourceTree = ""; }; D7458F302E0BDCF4000C957A /* linphoneExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (D7458F3C2E0BDCF4000C957A /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = linphoneExtension; sourceTree = ""; }; D792F15B2F02BCC2002E3225 /* intentsExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (D792F1642F02BCC2002E3225 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = intentsExtension; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -552,6 +572,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C65271092F88D1CA00FF248C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C6E408AC2F8E1A33003E0F8C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D719ABB02ABC67BF00B41C10 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -698,6 +732,7 @@ D717071C2AC591EF0037746F /* Utils */ = { isa = PBXGroup; children = ( + C65271052F87D3E600FF248C /* MDMManager.swift */, D7BC10D82F4EF64E00F09BDA /* AudioMode.swift */, D7BC10D32F4EF18100F09BDA /* SoundPlayer.swift */, C642277A2E8E4AC50094FEDC /* ThemeManager.swift */, @@ -732,6 +767,8 @@ 660AAF7C2B839272004C0FA6 /* msgNotificationService */, D7458F302E0BDCF4000C957A /* linphoneExtension */, D792F15B2F02BCC2002E3225 /* intentsExtension */, + C652710D2F88D1CA00FF248C /* LinphoneAppUITests */, + C6E408B02F8E1A33003E0F8C /* LinphoneAppTests */, D7DF8BE52E2104DC003A3BC7 /* Frameworks */, D719ABB42ABC67BF00B41C10 /* Products */, D7AEB9462F29128500298546 /* Shared.xcconfig */, @@ -745,6 +782,8 @@ 660AAF7B2B839271004C0FA6 /* msgNotificationService.appex */, D7458F2F2E0BDCF4000C957A /* linphoneExtension.appex */, D792F1582F02BCC2002E3225 /* intentsExtension.appex */, + C652710C2F88D1CA00FF248C /* LinphoneAppUITests.xctest */, + C6E408AF2F8E1A33003E0F8C /* LinphoneAppTests.xctest */, ); name = Products; sourceTree = ""; @@ -1261,6 +1300,52 @@ productReference = 660AAF7B2B839271004C0FA6 /* msgNotificationService.appex */; productType = "com.apple.product-type.app-extension"; }; + C652710B2F88D1CA00FF248C /* LinphoneAppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C65271142F88D1CA00FF248C /* Build configuration list for PBXNativeTarget "LinphoneAppUITests" */; + buildPhases = ( + C65271082F88D1CA00FF248C /* Sources */, + C65271092F88D1CA00FF248C /* Frameworks */, + C652710A2F88D1CA00FF248C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + C65271132F88D1CA00FF248C /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + C652710D2F88D1CA00FF248C /* LinphoneAppUITests */, + ); + name = LinphoneAppUITests; + packageProductDependencies = ( + ); + productName = LinphoneAppUITests; + productReference = C652710C2F88D1CA00FF248C /* LinphoneAppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + C6E408AE2F8E1A33003E0F8C /* LinphoneAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C6E408B52F8E1A33003E0F8C /* Build configuration list for PBXNativeTarget "LinphoneAppTests" */; + buildPhases = ( + C6E408AB2F8E1A33003E0F8C /* Sources */, + C6E408AC2F8E1A33003E0F8C /* Frameworks */, + C6E408AD2F8E1A33003E0F8C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + C6E408B42F8E1A33003E0F8C /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + C6E408B02F8E1A33003E0F8C /* LinphoneAppTests */, + ); + name = LinphoneAppTests; + packageProductDependencies = ( + ); + productName = LinphoneAppTests; + productReference = C6E408AF2F8E1A33003E0F8C /* LinphoneAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; D719ABB22ABC67BF00B41C10 /* LinphoneApp */ = { isa = PBXNativeTarget; buildConfigurationList = D719ABC22ABC67BF00B41C10 /* Build configuration list for PBXNativeTarget "LinphoneApp" */; @@ -1342,6 +1427,14 @@ CreatedOnToolsVersion = 15.0.1; LastSwiftMigration = 1500; }; + C652710B2F88D1CA00FF248C = { + CreatedOnToolsVersion = 26.2; + TestTargetID = D719ABB22ABC67BF00B41C10; + }; + C6E408AE2F8E1A33003E0F8C = { + CreatedOnToolsVersion = 26.2; + TestTargetID = D719ABB22ABC67BF00B41C10; + }; D719ABB22ABC67BF00B41C10 = { CreatedOnToolsVersion = 14.3.1; }; @@ -1392,6 +1485,8 @@ 660AAF7A2B839271004C0FA6 /* msgNotificationService */, D7458F2E2E0BDCF4000C957A /* linphoneExtension */, D792F1572F02BCC2002E3225 /* intentsExtension */, + C652710B2F88D1CA00FF248C /* LinphoneAppUITests */, + C6E408AE2F8E1A33003E0F8C /* LinphoneAppTests */, ); }; /* End PBXProject section */ @@ -1407,6 +1502,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C652710A2F88D1CA00FF248C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C6E408AD2F8E1A33003E0F8C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D719ABB12ABC67BF00B41C10 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1500,6 +1609,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C65271082F88D1CA00FF248C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C6E408AB2F8E1A33003E0F8C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; D719ABAF2ABC67BF00B41C10 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1571,6 +1694,7 @@ D7E2E69F2CE356C90080DA0D /* PopupViewWithTextField.swift in Sources */, C67586B52C09F617002E77BF /* SingleSignOnManager.swift in Sources */, D7F4D9CB2B5FD27200CDCD76 /* CallsListFragment.swift in Sources */, + C65271062F87D3E600FF248C /* MDMManager.swift in Sources */, C6A5A9482C10B6A30070FEA4 /* AuthState.swift in Sources */, D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */, D711B1342E93F18700DF8C71 /* LdapServerConfigurationFragment.swift in Sources */, @@ -1712,6 +1836,16 @@ target = 660AAF7A2B839271004C0FA6 /* msgNotificationService */; targetProxy = 660AAF7D2B839272004C0FA6 /* PBXContainerItemProxy */; }; + C65271132F88D1CA00FF248C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D719ABB22ABC67BF00B41C10 /* LinphoneApp */; + targetProxy = C65271122F88D1CA00FF248C /* PBXContainerItemProxy */; + }; + C6E408B42F8E1A33003E0F8C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D719ABB22ABC67BF00B41C10 /* LinphoneApp */; + targetProxy = C6E408B32F8E1A33003E0F8C /* PBXContainerItemProxy */; + }; D7458F382E0BDCF4000C957A /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D7458F2E2E0BDCF4000C957A /* linphoneExtension */; @@ -1863,6 +1997,120 @@ }; name = Release; }; + C65271152F88D1CA00FF248C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Z2V957B3D6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.linphone.LinphoneAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = LinphoneApp; + }; + name = Debug; + }; + C65271162F88D1CA00FF248C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Z2V957B3D6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.linphone.LinphoneAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = LinphoneApp; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C6E408B62F8E1A33003E0F8C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Z2V957B3D6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.linphone.LinphoneAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LinphoneApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/LinphoneApp"; + }; + name = Debug; + }; + C6E408B72F8E1A33003E0F8C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Z2V957B3D6; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.linphone.LinphoneAppTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LinphoneApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/LinphoneApp"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; D719ABC02ABC67BF00B41C10 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = D7AEB9462F29128500298546 /* Shared.xcconfig */; @@ -2260,6 +2508,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C65271142F88D1CA00FF248C /* Build configuration list for PBXNativeTarget "LinphoneAppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C65271152F88D1CA00FF248C /* Debug */, + C65271162F88D1CA00FF248C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C6E408B52F8E1A33003E0F8C /* Build configuration list for PBXNativeTarget "LinphoneAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C6E408B62F8E1A33003E0F8C /* Debug */, + C6E408B72F8E1A33003E0F8C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; D719ABAE2ABC67BF00B41C10 /* Build configuration list for PBXProject "LinphoneApp" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/LinphoneApp.xcodeproj/xcshareddata/xcschemes/Linphone.xcscheme b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/Linphone.xcscheme index 1b4ab3ccf..3e0fa3af5 100644 --- a/LinphoneApp.xcodeproj/xcshareddata/xcschemes/Linphone.xcscheme +++ b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/Linphone.xcscheme @@ -28,6 +28,30 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LinphoneApp.xcodeproj/xcshareddata/xcschemes/LinphoneAppUITests.xcscheme b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/LinphoneAppUITests.xcscheme new file mode 100644 index 000000000..25fbbf426 --- /dev/null +++ b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/LinphoneAppUITests.xcscheme @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/LinphoneApp.xcodeproj/xcshareddata/xcschemes/intentsExtension.xcscheme b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/intentsExtension.xcscheme new file mode 100644 index 000000000..3fa8ae201 --- /dev/null +++ b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/intentsExtension.xcscheme @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LinphoneApp.xcodeproj/xcshareddata/xcschemes/linphoneExtension.xcscheme b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/linphoneExtension.xcscheme new file mode 100644 index 000000000..3ce32cff3 --- /dev/null +++ b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/linphoneExtension.xcscheme @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LinphoneApp.xcodeproj/xcshareddata/xcschemes/msgNotificationService.xcscheme b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/msgNotificationService.xcscheme new file mode 100644 index 000000000..c689337b3 --- /dev/null +++ b/LinphoneApp.xcodeproj/xcshareddata/xcschemes/msgNotificationService.xcscheme @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LinphoneAppTests/MDMManagerTests.swift b/LinphoneAppTests/MDMManagerTests.swift new file mode 100644 index 000000000..4e51027ab --- /dev/null +++ b/LinphoneAppTests/MDMManagerTests.swift @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import XCTest +import linphonesw +@testable import LinphoneApp + +class MDMManagerTests: XCTestCase { + + private let managedKey = "com.apple.configuration.managed" + private let dummyRootCa = """ + -----BEGIN CERTIFICATE----- + MIIDdummyTESTCERTIFICATEVALUEForUnitTestsOnly1234567890abcdefABCDEF + -----END CERTIFICATE----- + """ + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: managedKey) + UserDefaults.standard.removeObject(forKey: "MDMManager.hasMDMConfig") + UserDefaults.standard.removeObject(forKey: "MDMManager.lastConfigSHA256") + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: managedKey) + UserDefaults.standard.removeObject(forKey: "MDMManager.hasMDMConfig") + UserDefaults.standard.removeObject(forKey: "MDMManager.lastConfigSHA256") + super.tearDown() + } + + func testApplyMdmConfigSetsRootCa() throws { + let mdmConfig: [String: Any] = ["root-ca": dummyRootCa] + UserDefaults.standard.set(mdmConfig, forKey: managedKey) + + let config = Config.newForSharedCore( + appGroupId: Bundle.main.object(forInfoDictionaryKey: "APP_GROUP_NAME") as? String ?? "group.test", + configFilename: "linphonerc-test", + factoryConfigFilename: nil + ) + + let core = try Factory.Instance.createCoreWithConfig(config: config!, systemContext: nil) + + let appliedExpectation = expectation(forNotification: MDMManager.configurationAppliedNotification, object: nil) { notification in + guard let config = notification.userInfo?["config"] as? [String: Any] else { return false } + return (config["root-ca"] as? String) == self.dummyRootCa + } + + MDMManager.shared.applyMdmConfigToCore(core: core) + + wait(for: [appliedExpectation], timeout: 5) + + XCTAssertEqual(core.rootCaData, dummyRootCa, + "core.rootCaData should equal the MDM-provided root-ca after applyMdmConfigToCore") + } + +} diff --git a/LinphoneAppUITests/MDMChatFeatureUITests.swift b/LinphoneAppUITests/MDMChatFeatureUITests.swift new file mode 100644 index 000000000..e905ef4f0 --- /dev/null +++ b/LinphoneAppUITests/MDMChatFeatureUITests.swift @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import XCTest + +class MDMChatFeatureUITests: XCTestCase { + + let app = XCUIApplication() + private var permissionMonitor: NSObjectProtocol? + + override func setUp() { + super.setUp() + continueAfterFailure = false + + permissionMonitor = addUIInterruptionMonitor(withDescription: "System Permission Alert") { alert in + let prefixMatches = ["Share All", "Allow"] + for prefix in prefixMatches { + let match = alert.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", prefix)).firstMatch + if match.exists { + match.tap() + return true + } + } + let allowLabels = ["Continue", "OK", "Allow Once", "Always Allow"] + for label in allowLabels { + let button = alert.buttons[label] + if button.exists { + button.tap() + return true + } + } + return false + } + } + + override func tearDown() { + if let monitor = permissionMonitor { + removeUIInterruptionMonitor(monitor) + } + super.tearDown() + } + + private func requiredEnv(_ name: String) -> String { + guard let value = ProcessInfo.processInfo.environment[name], !value.isEmpty else { + XCTFail("Missing required env var \(name). Export TEST_RUNNER_\(name) before running scripts/run-mdm-tests.sh") + return "" + } + return value + } + + private func launchWithMDM(_ mdm: [String: Any]) { + let data = try! JSONSerialization.data(withJSONObject: mdm) + app.launchEnvironment["UITEST_MDM_CONFIG"] = String(data: data, encoding: .utf8)! + app.launch() + _ = app.wait(for: .runningForeground, timeout: 15) + } + + private func skipOnboardingIfShown() { + let welcomeSkip = app.buttons["welcome_skip_button"] + if welcomeSkip.waitForExistence(timeout: 5) { + welcomeSkip.tap() + } + let permissionsSkip = app.buttons["permissions_skip_button"] + if permissionsSkip.waitForExistence(timeout: 5) { + permissionsSkip.tap() + } + } + + private func waitForMainPage(timeout: TimeInterval) -> Bool { + let callsButton = app.buttons.matching(NSPredicate(format: "label == %@", "Calls")).firstMatch + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let alertPrefixes = ["Share All", "Allow", "Continue", "OK"] + + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + for prefix in alertPrefixes { + let button = springboard.buttons.matching( + NSPredicate(format: "label BEGINSWITH %@", prefix) + ).firstMatch + if button.exists { + button.tap() + break + } + } + if callsButton.exists { + return true + } + _ = callsButton.waitForExistence(timeout: 1) + } + return false + } + + private func dumpOnFailure(_ label: String) { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + print("=== APP DEBUG DESCRIPTION (\(label)) ===") + print(app.debugDescription) + print("=== SPRINGBOARD DEBUG DESCRIPTION =====================") + print(springboard.debugDescription) + print("=======================================================") + } + + func testChatButtonHiddenWithMDMDisableChat() { + let username = requiredEnv("LINPHONE_TEST_USERNAME") + let ha1 = requiredEnv("LINPHONE_TEST_HA1") + let domain = ProcessInfo.processInfo.environment["LINPHONE_TEST_DOMAIN"] ?? "sip.linphone.org" + + let xmlConfig = """ + + +
+ 1 +
+
+ sip:\(username)@\(domain) + <sip:\(domain);transport=tls> + <sip:\(domain);transport=tls> + \(domain) + https://lime.linphone.org:443/lime-server/lime-server.php +
+
+ \(username) + \(domain) + \(ha1) + \(domain) + MD5 +
+
+ """ + + launchWithMDM(["xml-config": xmlConfig]) + skipOnboardingIfShown() + + let landed = waitForMainPage(timeout: 30) + if !landed { dumpOnFailure("main screen not reached") } + XCTAssertTrue(landed, "Should have landed on the main screen after Welcome + Permissions") + + let chatButton = app.buttons.matching(NSPredicate(format: "label == %@", "Conversations")).firstMatch + XCTAssertFalse(chatButton.exists, + "Chat button should NOT be visible when MDM disables chat feature") + } + + func testConfigUriMDMLandsOnMainPage() { + let configUri = requiredEnv("LINPHONE_TEST_CONFIG_URI") + + launchWithMDM(["config-uri": configUri]) + skipOnboardingIfShown() + + let landed = waitForMainPage(timeout: 60) + if !landed { dumpOnFailure("main screen not reached with config-uri \(configUri)") } + XCTAssertTrue(landed, + "Should have landed on the main screen after remote provisioning from \(configUri)") + } +} diff --git a/README.md b/README.md index eb049f870..3bf80f471 100644 --- a/README.md +++ b/README.md @@ -133,3 +133,117 @@ cmake --preset=ios-sdk -G Ninja -B spm-ios && cmake --build spm-ios - Add it manually if needed. ![Image6](ReadmeImages/ReadmeImage6.png) + +# MDM (Mobile Device Management) configuration + +Linphone iOS supports managed app configuration via the standard iOS MDM +`com.apple.configuration.managed` mechanism. When the app is deployed through +an MDM server, administrators can push a configuration dictionary that the app +reads at startup and whenever the managed configuration changes at runtime. + +The following keys are supported: + +| Key | Type | Description | +|---------------|--------|-------------------------------------------------------------------------------------------------| +| `xml-config` | String | A Linphone configuration in XML format (same schema as `linphonerc`). Applied via `Config.loadFromXmlString`. | +| `root-ca` | String | A PEM-encoded root CA certificate used by the Linphone SDK for TLS operations (SIPS, HTTPS provisioning, …). Applied to `core.rootCaData`. | +| `config-uri` | String | URI to a remote provisioning file. When set, it takes precedence over any `config-uri` that may be defined inside `xml-config`, and triggers a core restart to fetch the remote configuration. | + +Notes: +- All three keys are optional and can be combined. +- If `config-uri` is present, it is set last and the core is restarted so that + remote provisioning takes effect; any `config-uri` value embedded in + `xml-config` is therefore overridden. +- Applying and removing the managed configuration at runtime is supported: + removing it resets the core to its default configuration and returns to the + assistant/login screen. + +## Testing MDM configuration + +Two kinds of tests are provided: + +### UI tests (end-to-end) + +Located in `LinphoneAppUITests/MDMChatFeatureUITests.swift`. They inject a +managed configuration at launch via the app's DEBUG-only +`UITEST_MDM_CONFIG` launch-environment hook (implemented in +`Linphone/LinphoneApp.swift`), so no `xcrun simctl` setup is needed. + +Note: the tests only cover MDM *application* (fresh launch with a managed +config). Live removal of MDM while the app is running cannot be simulated +from an XCUITest process (UserDefaults is per-process and we want the app +to stay alive for a realistic removal scenario), so that path is covered by +manual testing only. + +Each MDM test case represents "a fresh device receiving a specific managed +configuration", so we uninstall the app before every test to avoid any +leakage of UserDefaults / keychain / provisioning / accounts between cases. +The wrapper script `scripts/run-mdm-tests.sh` does this for you. + +The tests need a real SIP account to reach the main screen (the MDM XML +embeds proxy + auth_info sections) and a remote provisioning URL for the +config-uri test. Credentials can be provided three ways — the script +resolves them in this order, highest first: + +1. CLI flags: `--username`, `--ha1`, `--domain`, `--config-uri` (and + `--device` for the sim UUID) +2. Shell env vars: `LINPHONE_TEST_USERNAME`, `LINPHONE_TEST_HA1`, + `LINPHONE_TEST_DOMAIN`, `LINPHONE_TEST_CONFIG_URI` +3. The gitignored file `scripts/test-credentials.env` (copy from `.env.example`) + +Examples: + +```bash +scripts/run-mdm-tests.sh --device --username alice --ha1 --config-uri https://example.com/provisioning.xml +``` + +```bash +cp scripts/test-credentials.env.example scripts/test-credentials.env +# edit scripts/test-credentials.env, fill in LINPHONE_TEST_USERNAME / +# LINPHONE_TEST_HA1 / LINPHONE_TEST_CONFIG_URI +scripts/run-mdm-tests.sh +``` + +It will create+boot a throwaway simulator if `DEVICE_UUID` is not set, +uninstall the app before each test, run the tests one at a time with +`-parallel-testing-enabled NO`, and clean up at the end. To reuse an +already-booted simulator: + +```bash +DEVICE_UUID= scripts/run-mdm-tests.sh +``` + +`-parallel-testing-enabled NO` avoids flaky UI test launch failures caused by +Xcode spinning up multiple simulator clones in parallel (the test-runner app +can fail to launch on a clone under pressure). + +Covered cases: +- `testChatButtonHiddenWithMDMDisableChat` — MDM `xml-config` with + `disable_chat_feature=1`; the test reaches the main screen and asserts the + chat button is hidden. +- `testConfigUriMDMLandsOnMainPage` — MDM `config-uri` pointing at the URL + supplied via `--config-uri` / `LINPHONE_TEST_CONFIG_URI`; the test verifies + that remote provisioning completes and the app lands on the main screen. + +### Unit tests (MDMManager) + +Located in `LinphoneAppTests/MDMManagerTests.swift`. The unit test covers +only `root-ca` application: it calls +`MDMManager.shared.applyMdmConfigToCore(core:)` directly on a throwaway +`Core` and asserts `core.rootCaData` matches the MDM-provided certificate. +The `config-uri` and `xml-config` paths are exercised end-to-end by the UI +tests above. + +This requires a **Unit Testing Bundle** target in Xcode (separate from the UI +test target, because `@testable import Linphone` only works from a unit test +bundle): + +1. Xcode → File → New → Target → iOS → Unit Testing Bundle +2. Name it `LinphoneAppTests`, set "Target to be Tested" to `LinphoneApp` +3. Add `LinphoneAppTests/MDMManagerTests.swift` to that target + +Then run: + +```bash +xcodebuild test -project LinphoneApp.xcodeproj -scheme LinphoneAppTests -destination "platform=iOS Simulator,id=$DEVICE_UUID" +``` diff --git a/scripts/run-mdm-tests.sh b/scripts/run-mdm-tests.sh new file mode 100755 index 000000000..52d11e9fd --- /dev/null +++ b/scripts/run-mdm-tests.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# +# Run MDM UI tests with a clean app install before each test case. +# +# Each MDM UI test scenario represents a fresh device receiving a specific +# managed configuration. To reproduce that "closest to reality", we uninstall +# the app before every single test so no state (UserDefaults, keychain, +# provisioning, accounts) leaks between runs. +# +# Usage: +# scripts/run-mdm-tests.sh [--device ] [--username ] [--ha1 ] [--domain ] +# +# Or with environment variables / the gitignored `scripts/test-credentials.env`: +# DEVICE_UUID= LINPHONE_TEST_USERNAME=... LINPHONE_TEST_HA1=... scripts/run-mdm-tests.sh +# +# Resolution order (highest first): CLI flag > shell env > test-credentials.env > default. +# +# If no device UUID is given, the script creates and boots a throwaway +# "iPhone 15" simulator, then deletes it at the end. +# +# SIP test credentials are required for tests that need a working account. +# They are forwarded to the UI test runner via the `TEST_RUNNER_` prefix, +# which Xcode strips before exposing them to the test process. +# LINPHONE_TEST_USERNAME SIP username (required) +# LINPHONE_TEST_HA1 md5(username:realm:password) (required) +# LINPHONE_TEST_DOMAIN defaults to sip.linphone.org +# LINPHONE_TEST_CONFIG_URI remote provisioning URL (required for the +# config-uri UI test) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -f "$SCRIPT_DIR/test-credentials.env" ]]; then + # shellcheck source=/dev/null + source "$SCRIPT_DIR/test-credentials.env" +fi + +CLI_DEVICE="" +CLI_USERNAME="" +CLI_HA1="" +CLI_DOMAIN="" +CLI_CONFIG_URI="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --device) CLI_DEVICE="$2"; shift 2 ;; + --username) CLI_USERNAME="$2"; shift 2 ;; + --ha1) CLI_HA1="$2"; shift 2 ;; + --domain) CLI_DOMAIN="$2"; shift 2 ;; + --config-uri) CLI_CONFIG_URI="$2"; shift 2 ;; + -h|--help) + sed -n '3,20p' "$0" + exit 0 + ;; + -*) + echo "Unknown flag: $1" >&2 + exit 2 + ;; + *) + # First bare positional arg = device UUID (back-compat). + if [[ -z "$CLI_DEVICE" ]]; then CLI_DEVICE="$1"; shift + else echo "Unexpected argument: $1" >&2; exit 2 + fi + ;; + esac +done + +LINPHONE_TEST_USERNAME="${CLI_USERNAME:-${LINPHONE_TEST_USERNAME:-}}" +LINPHONE_TEST_HA1="${CLI_HA1:-${LINPHONE_TEST_HA1:-}}" +LINPHONE_TEST_DOMAIN="${CLI_DOMAIN:-${LINPHONE_TEST_DOMAIN:-sip.linphone.org}}" +LINPHONE_TEST_CONFIG_URI="${CLI_CONFIG_URI:-${LINPHONE_TEST_CONFIG_URI:-}}" + +: "${LINPHONE_TEST_USERNAME:?LINPHONE_TEST_USERNAME is required (pass --username, export it, or put it in scripts/test-credentials.env)}" +: "${LINPHONE_TEST_HA1:?LINPHONE_TEST_HA1 is required (pass --ha1, export it, or put it in scripts/test-credentials.env)}" +: "${LINPHONE_TEST_CONFIG_URI:?LINPHONE_TEST_CONFIG_URI is required (pass --config-uri, export it, or put it in scripts/test-credentials.env)}" +export TEST_RUNNER_LINPHONE_TEST_USERNAME="$LINPHONE_TEST_USERNAME" +export TEST_RUNNER_LINPHONE_TEST_HA1="$LINPHONE_TEST_HA1" +export TEST_RUNNER_LINPHONE_TEST_DOMAIN="$LINPHONE_TEST_DOMAIN" +export TEST_RUNNER_LINPHONE_TEST_CONFIG_URI="$LINPHONE_TEST_CONFIG_URI" + +BUNDLE_ID="org.linphone.phone" +PROJECT="LinphoneApp.xcodeproj" +SCHEME="LinphoneAppUITests" +TEST_CLASS="LinphoneAppUITests/MDMChatFeatureUITests" + +# App groups survive `simctl uninstall`, so linphonerc / SDK state from a +# previous test leaks into the next fresh install. We nuke these between +# tests so each test really starts from scratch. +APP_GROUPS=( + "group.org.linphone.phone.msgNotification" + "group.org.linphone.phone.linphoneExtension" +) + +TESTS=( + "testChatButtonHiddenWithMDMDisableChat" + "testConfigUriMDMLandsOnMainPage" +) + +CREATED_DEVICE=0 +DEVICE_UUID="${CLI_DEVICE:-${DEVICE_UUID:-}}" + +if [[ -z "$DEVICE_UUID" ]]; then + echo "No DEVICE_UUID provided, creating a throwaway simulator..." + DEVICE_UUID=$(xcrun simctl create "LinphoneMDMTest" "iPhone 15") + xcrun simctl boot "$DEVICE_UUID" + CREATED_DEVICE=1 +else + # Make sure the simulator is booted — simctl uninstall fails on Shutdown. + # `simctl boot` is a no-op on an already-booted device except for exit code, + # so we ignore that specific error. + xcrun simctl boot "$DEVICE_UUID" 2>/dev/null || true +fi + +cleanup() { + if [[ "$CREATED_DEVICE" == "1" ]]; then + xcrun simctl shutdown "$DEVICE_UUID" || true + xcrun simctl delete "$DEVICE_UUID" || true + fi +} +trap cleanup EXIT + +for test in "${TESTS[@]}"; do + echo "" + echo "==============================================" + echo "Running $test on $DEVICE_UUID" + echo "==============================================" + # Wipe app group containers BEFORE uninstall — get_app_container needs the + # app installed to resolve the group path. On the very first iteration the + # app isn't installed yet (nothing to leak), so the lookup silently no-ops. + for group in "${APP_GROUPS[@]}"; do + group_path=$(xcrun simctl get_app_container "$DEVICE_UUID" "$BUNDLE_ID" "$group" 2>/dev/null || true) + if [[ -n "$group_path" && -d "$group_path" ]]; then + echo "Wiping app group container: $group_path" + rm -rf "$group_path"/* "$group_path"/.[!.]* 2>/dev/null || true + fi + done + xcrun simctl uninstall "$DEVICE_UUID" "$BUNDLE_ID" || true + # Keychain survives simctl uninstall — once the core materializes an account + # from MDM it saves auth info to the keychain, which would make the next + # fresh install skip the welcome flow. Reset the keychain between tests. + xcrun simctl keychain "$DEVICE_UUID" reset || true + # Pre-grant privacy-sensitive permissions so no system dialogs interrupt the + # flow (Contacts triggers an extra "Share All N Contacts" limited-access + # sheet on iOS 18 that UIInterruptionMonitor can only dismiss if another app + # interaction follows — not worth juggling). Must happen after uninstall + + # before the test launches the app. Ignore failures (not all sims/iOS + # versions support every service). + for service in contacts notifications location location-always camera microphone photos; do + xcrun simctl privacy "$DEVICE_UUID" grant "$service" "$BUNDLE_ID" 2>/dev/null || true + done + xcodebuild test -project "$PROJECT" -scheme "$SCHEME" -destination "platform=iOS Simulator,id=$DEVICE_UUID" -only-testing:"$TEST_CLASS/$test" -parallel-testing-enabled NO +done + +echo "" +echo "All MDM UI tests passed."