diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 28fba47cb1..1fc46c1736 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -271,7 +271,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { title: L10n.Updater.UpdateAvailable.open(update.name), style: .default, handler: { _ in - UIApplication.shared.open(update.htmlUrl, options: [:], completionHandler: nil) + Current.urlOpener.open(update.htmlUrl, options: [:], completionHandler: nil) } )) alert.addAction(UIAlertAction(title: L10n.okLabel, style: .cancel, handler: nil)) diff --git a/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift b/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift index ad1fea92bf..455d717ffe 100644 --- a/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift +++ b/Sources/App/Onboarding/Steps/Permissions/OnboardingPermissionsNavigationViewModel.swift @@ -209,7 +209,7 @@ final class OnboardingPermissionsNavigationViewModel: NSObject, ObservableObject case .denied, .restricted: // Open iOS settings for user to manually enable location if let settingsUrl = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(settingsUrl) + Current.urlOpener.open(settingsUrl, options: [:], completionHandler: nil) } case .authorizedWhenInUse, .authorizedAlways: // Permission already granted, apply the context-specific needs diff --git a/Sources/App/Scenes/WebViewSceneDelegate.swift b/Sources/App/Scenes/WebViewSceneDelegate.swift index ba61706133..6cdfc8866b 100644 --- a/Sources/App/Scenes/WebViewSceneDelegate.swift +++ b/Sources/App/Scenes/WebViewSceneDelegate.swift @@ -46,7 +46,7 @@ final class WebViewSceneDelegate: NSObject, UIWindowSceneDelegate { let shortcutItem = connectionOptions.shortcutItem { self.windowScene(scene, performActionFor: shortcutItem, completionHandler: { _ in }) } else if let url = Current.servers.all.first?.info.connection.activeURL() { - UIApplication.shared.open(url) + Current.urlOpener.open(url, options: [:], completionHandler: nil) // Close window to avoid empty window left behind if let scene = window.windowScene { UIApplication.shared.requestSceneSessionDestruction(scene.session, options: nil, errorHandler: nil) diff --git a/Sources/App/Settings/Connection/ConnectionSettingsViewController.swift b/Sources/App/Settings/Connection/ConnectionSettingsViewController.swift index 37d4655d70..17f4d5c419 100644 --- a/Sources/App/Settings/Connection/ConnectionSettingsViewController.swift +++ b/Sources/App/Settings/Connection/ConnectionSettingsViewController.swift @@ -372,7 +372,7 @@ class ConnectionSettingsViewController: HAFormViewController, RowControllerType @objc private func activateServerTapped() { if Current.isCatalyst, Current.settingsStore.macNativeFeaturesOnly { if let url = server.info.connection.activeURL() { - UIApplication.shared.open(url) + Current.urlOpener.open(url, options: [:], completionHandler: nil) } } else { Current.sceneManager.webViewWindowControllerPromise.done { diff --git a/Sources/App/Settings/DebugView.swift b/Sources/App/Settings/DebugView.swift index 6eb78b53a1..da12075205 100644 --- a/Sources/App/Settings/DebugView.swift +++ b/Sources/App/Settings/DebugView.swift @@ -38,7 +38,7 @@ struct DebugView: View { logsURL = url if Current.isCatalyst { if let url = Current.Log.archiveURL() { - UIApplication.shared.open(url, options: [:], completionHandler: nil) + Current.urlOpener.open(url, options: [:], completionHandler: nil) } } else { loadingLogs = true diff --git a/Sources/App/Settings/Notifications/NotificationSoundsViewController.swift b/Sources/App/Settings/Notifications/NotificationSoundsViewController.swift index e2a2357e13..135a2e080d 100644 --- a/Sources/App/Settings/Notifications/NotificationSoundsViewController.swift +++ b/Sources/App/Settings/Notifications/NotificationSoundsViewController.swift @@ -100,7 +100,7 @@ class NotificationSoundsViewController: HAFormViewController, UIDocumentPickerDe $0.title = L10n.SettingsDetails.Notifications.Sounds.importMacOpenFolder $0.onCellSelection { _, _ in do { - try UIApplication.shared.open(self.librarySoundsURL(), options: [:], completionHandler: nil) + try Current.urlOpener.open(self.librarySoundsURL(), options: [:], completionHandler: nil) } catch { Current.Log.error("couldn't open folder: \(error)") } diff --git a/Sources/App/Settings/SettingsDetailViewController.swift b/Sources/App/Settings/SettingsDetailViewController.swift index 1adb6e802b..9ed35e372f 100644 --- a/Sources/App/Settings/SettingsDetailViewController.swift +++ b/Sources/App/Settings/SettingsDetailViewController.swift @@ -195,7 +195,7 @@ class SettingsDetailViewController: HAFormViewController, TypedRowControllerType $0.accessoryIcon = .openInNewIcon $0.onCellSelection { _, row in guard let url = URL(string: "https://companion.home-assistant.io/docs/core/actions/") else { return } - UIApplication.shared.open(url) + Current.urlOpener.open(url, options: [:], completionHandler: nil) row.deselect(animated: true) } } diff --git a/Sources/App/Utilities/Extensions/UIApplication+OpenSettings.swift b/Sources/App/Utilities/Extensions/UIApplication+OpenSettings.swift index 68480c58ae..854ff1d6b5 100644 --- a/Sources/App/Utilities/Extensions/UIApplication+OpenSettings.swift +++ b/Sources/App/Utilities/Extensions/UIApplication+OpenSettings.swift @@ -42,7 +42,7 @@ extension UIApplication { func openSettings(destination: OpenSettingsDestination, completionHandler: ((Bool) -> Void)? = nil) { if let url = destination.url { - UIApplication.shared.open(url, options: [:], completionHandler: completionHandler) + Current.urlOpener.open(url, options: [:], completionHandler: completionHandler) } else { completionHandler?(false) } diff --git a/Sources/App/Utilities/OpenInChromeController.swift b/Sources/App/Utilities/OpenInChromeController.swift index 33abf9d80d..50e350f848 100644 --- a/Sources/App/Utilities/OpenInChromeController.swift +++ b/Sources/App/Utilities/OpenInChromeController.swift @@ -54,7 +54,7 @@ open class OpenInChromeController { open func isChromeInstalled() -> Bool { if let simpleURL = URL(string: googleChromeHTTPScheme), let callbackURL = URL(string: googleChromeCallbackScheme) { - return UIApplication.shared.canOpenURL(simpleURL) || UIApplication.shared.canOpenURL(callbackURL) + return Current.urlOpener.canOpenURL(simpleURL) || Current.urlOpener.canOpenURL(callbackURL) } return false } @@ -63,7 +63,7 @@ open class OpenInChromeController { open func openInChrome(_ url: URL, callbackURL: URL?, createNewTab: Bool = false) { if let chromeSimpleURL = URL(string: googleChromeHTTPScheme), let chromeCallbackURL = URL(string: googleChromeCallbackScheme) { - if UIApplication.shared.canOpenURL(chromeCallbackURL) { + if Current.urlOpener.canOpenURL(chromeCallbackURL) { var appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String // CFBundleDisplayName is an optional key, so we will use CFBundleName if it does not exist if appName == nil { @@ -87,10 +87,10 @@ open class OpenInChromeController { chromeURLString += "&create-new-tab" } if let chromeURL = URL(string: chromeURLString) { - UIApplication.shared.open(chromeURL, options: [:], completionHandler: nil) + Current.urlOpener.open(chromeURL, options: [:], completionHandler: nil) } } - } else if UIApplication.shared.canOpenURL(chromeSimpleURL) { + } else if Current.urlOpener.canOpenURL(chromeSimpleURL) { let scheme = url.scheme?.lowercased() var chromeScheme: String? if scheme == "http" { @@ -102,7 +102,7 @@ open class OpenInChromeController { let absoluteURLString = url.absoluteString let lowerBound = absoluteURLString.range(of: ":")!.lowerBound let chromeURLString = chromeScheme + absoluteURLString.suffix(from: lowerBound) - UIApplication.shared.open(URL(string: chromeURLString)!, options: [:], completionHandler: nil) + Current.urlOpener.open(URL(string: chromeURLString)!, options: [:], completionHandler: nil) } } } diff --git a/Sources/App/Utilities/Utils.swift b/Sources/App/Utilities/Utils.swift index ad6b89fc9f..7a7a8ac2fa 100644 --- a/Sources/App/Utilities/Utils.swift +++ b/Sources/App/Utilities/Utils.swift @@ -21,7 +21,7 @@ func resetStores() { func openURLInBrowser(_ urlToOpen: URL, _ sender: UIViewController?) { guard ["http", "https"].contains(urlToOpen.scheme?.lowercased()) else { - UIApplication.shared.open(urlToOpen, options: [:], completionHandler: nil) + Current.urlOpener.open(urlToOpen, options: [:], completionHandler: nil) return } @@ -42,7 +42,7 @@ func openURLInBrowser(_ urlToOpen: URL, _ sender: UIViewController?) { let sfv = SFSafariViewController(url: urlToOpen) sender!.present(sfv, animated: true) default: - UIApplication.shared.open(urlToOpen, options: [:], completionHandler: nil) + Current.urlOpener.open(urlToOpen, options: [:], completionHandler: nil) } } diff --git a/Sources/App/WebView/DownloadManager/DownloadManagerView.swift b/Sources/App/WebView/DownloadManager/DownloadManagerView.swift index 50af50cda9..bc8700559f 100644 --- a/Sources/App/WebView/DownloadManager/DownloadManagerView.swift +++ b/Sources/App/WebView/DownloadManager/DownloadManagerView.swift @@ -28,7 +28,7 @@ struct DownloadManagerView: View { } .onChange(of: viewModel.finished) { _, newValue in if newValue, Current.isCatalyst { - UIApplication.shared.open(AppConstants.DownloadsDirectory) + Current.urlOpener.open(AppConstants.DownloadsDirectory, options: [:], completionHandler: nil) } } } @@ -83,7 +83,7 @@ struct DownloadManagerView: View { if let url = viewModel.lastURLCreated { if Current.isCatalyst { Button { - UIApplication.shared.open(AppConstants.DownloadsDirectory) + Current.urlOpener.open(AppConstants.DownloadsDirectory, options: [:], completionHandler: nil) } label: { Label(viewModel.fileName, systemSymbol: .folder) } diff --git a/Sources/App/WebView/Views/ConnectionErrorDetailsView.swift b/Sources/App/WebView/Views/ConnectionErrorDetailsView.swift index 35b8fd9e1e..36d898e850 100644 --- a/Sources/App/WebView/Views/ConnectionErrorDetailsView.swift +++ b/Sources/App/WebView/Views/ConnectionErrorDetailsView.swift @@ -196,7 +196,7 @@ struct ConnectionErrorDetailsView: View { tint: .haPrimary ) { if Current.isCatalyst, let logsURL = Current.Log.archiveURL() { - UIApplication.shared.open(logsURL) + Current.urlOpener.open(logsURL, options: [:], completionHandler: nil) } else { showExportLogsShareSheet = true feedbackGenerator.notificationOccurred(.success) diff --git a/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift b/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift index dc29faedfc..d16dc00649 100644 --- a/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift +++ b/Sources/App/WebView/Views/ConnectionSecurityLevelBlock/ConnectionSecurityLevelBlockView.swift @@ -75,7 +75,11 @@ struct ConnectionSecurityLevelBlockView: View { case .notDetermined: Current.Log.info("Location permission not determined") case .denied, .restricted: - UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + Current.urlOpener.open( + URL(string: UIApplication.openSettingsURLString)!, + options: [:], + completionHandler: nil + ) case .authorizedWhenInUse, .authorizedAlways: // Handle permission change - reload requirements to update UI viewModel.loadRequirements() diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index 826c1903ff..43edca7791 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -817,7 +817,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg urlComponents.queryItems = urlComponents.queryItems?.filter { $0.name != "external_auth" } if let url = urlComponents.url { - UIApplication.shared.open(url) + Current.urlOpener.open(url, options: [:], completionHandler: nil) } } } @@ -914,7 +914,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg buttonImage: nil, buttonTitle: L10n.openLabel, buttonTapHandler: { _ in - UIApplication.shared.open(alert.url, options: [:], completionHandler: nil) + Current.urlOpener.open(alert.url, options: [:], completionHandler: nil) SwiftMessages.hide() } ) diff --git a/Sources/Extensions/Widgets/OpenEntity/ControlOpenEntity/OpenEntityAppIntent.swift b/Sources/Extensions/Widgets/OpenEntity/ControlOpenEntity/OpenEntityAppIntent.swift index 54dfa4a93c..3eddb8c144 100644 --- a/Sources/Extensions/Widgets/OpenEntity/ControlOpenEntity/OpenEntityAppIntent.swift +++ b/Sources/Extensions/Widgets/OpenEntity/ControlOpenEntity/OpenEntityAppIntent.swift @@ -26,12 +26,12 @@ struct OpenEntityAppIntent: AppIntent { serverId: entity.serverId ) { DispatchQueue.main.async { - UIApplication.shared.open(url) + Current.urlOpener.open(url, options: [:], completionHandler: nil) } } else if let url = AppConstants.openEntityDeeplinkURL(entityId: entity.entityId, serverId: entity.serverId) { DispatchQueue.main.async { - UIApplication.shared.open(url) + Current.urlOpener.open(url, options: [:], completionHandler: nil) } } #endif diff --git a/Sources/Extensions/Widgets/OpenPage/OpenPageAppIntent.swift b/Sources/Extensions/Widgets/OpenPage/OpenPageAppIntent.swift index eaa66fc967..e14c97589b 100644 --- a/Sources/Extensions/Widgets/OpenPage/OpenPageAppIntent.swift +++ b/Sources/Extensions/Widgets/OpenPage/OpenPageAppIntent.swift @@ -29,7 +29,7 @@ struct OpenPageAppIntent: AppIntent { serverId: server.identifier.rawValue ) { DispatchQueue.main.async { - UIApplication.shared.open(url) + Current.urlOpener.open(url, options: [:], completionHandler: nil) } } #endif diff --git a/Sources/Shared/Environment/Environment.swift b/Sources/Shared/Environment/Environment.swift index 8ad255b7bb..606250f317 100644 --- a/Sources/Shared/Environment/Environment.swift +++ b/Sources/Shared/Environment/Environment.swift @@ -486,4 +486,7 @@ public class AppEnvironment { public var appSessionValues: AppSessionValuesProtocol = AppSessionValues.shared public var locationManager: LocationManagerProtocol = LocationManager() + + /// Provides a way to open URLs and check if URLs can be opened. + public var urlOpener: URLOpenerServiceProtocol = URLOpenerServiceImpl() } diff --git a/Sources/Shared/Services/URLOpenerService.swift b/Sources/Shared/Services/URLOpenerService.swift new file mode 100644 index 0000000000..3fab557c76 --- /dev/null +++ b/Sources/Shared/Services/URLOpenerService.swift @@ -0,0 +1,40 @@ +import Foundation +import UIKit + +public protocol URLOpenerServiceProtocol { + @discardableResult + func open( + _ url: URL, + options: [UIApplication.OpenExternalURLOptionsKey: Any], + completionHandler: ((Bool) -> Void)? + ) -> Bool + + func canOpenURL(_ url: URL) -> Bool +} + +public final class URLOpenerServiceImpl: URLOpenerServiceProtocol { + public init() {} + + @discardableResult + public func open( + _ url: URL, + options: [UIApplication.OpenExternalURLOptionsKey: Any], + completionHandler: ((Bool) -> Void)? + ) -> Bool { + #if os(iOS) + UIApplication.shared.open(url, options: options, completionHandler: completionHandler) + return true + #else + completionHandler?(false) + return false + #endif + } + + public func canOpenURL(_ url: URL) -> Bool { + #if os(iOS) + return UIApplication.shared.canOpenURL(url) + #else + return false + #endif + } +} diff --git a/Tests/Shared/URLOpenerService.test.swift b/Tests/Shared/URLOpenerService.test.swift new file mode 100644 index 0000000000..f8a01d5f55 --- /dev/null +++ b/Tests/Shared/URLOpenerService.test.swift @@ -0,0 +1,46 @@ +import Foundation +@testable import Shared +import XCTest + +class URLOpenerServiceTests: XCTestCase { + func testProtocolExists() { + // Test that the protocol can be used + let service: URLOpenerServiceProtocol = URLOpenerServiceImpl() + XCTAssertNotNil(service) + } + + func testCanOpenURL() { + let service = URLOpenerServiceImpl() + + // Test with a valid URL scheme + if let url = URL(string: "https://www.apple.com") { + // We can't actually test the return value in unit tests without a real UIApplication + // but we can verify the method exists and doesn't crash + _ = service.canOpenURL(url) + } + } + + func testOpenURL() { + let service = URLOpenerServiceImpl() + + if let url = URL(string: "https://www.apple.com") { + // In unit tests, this will be called but won't actually open anything + // We just verify it doesn't crash + service.open(url, options: [:], completionHandler: nil) + } + } + + func testOpenURLWithCompletion() { + let service = URLOpenerServiceImpl() + let expectation = expectation(description: "Completion handler called") + + if let url = URL(string: "https://www.apple.com") { + service.open(url, options: [:]) { _ in + // In unit tests this may or may not succeed depending on environment + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + } +}