From b73c0c305fdb091ea0cf4456d6246ca12c756800 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 30 Jul 2025 08:19:11 +0300 Subject: [PATCH 01/24] fix: resolve issue 581 --- Core/Core.xcodeproj/project.pbxproj | 4 --- .../Container/CourseContainerViewModel.swift | 33 +++++++++++++++++-- .../Outline/CourseOutlineView.swift | 2 ++ Podfile.lock | 2 +- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 7c3cd8479..c60200710 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -1080,14 +1080,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests-resources.sh\"\n"; diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index b6da7b531..a8162b51d 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -193,11 +193,40 @@ public final class CourseContainerViewModel: BaseCourseViewModel { sequentialIndex: continueWith.sequentialIndex ) } - + + private func getCourseBlocksWithTimeout( + courseID: String, + timeoutSeconds: UInt64 + ) async throws -> CourseStructure? { + return try await withThrowingTaskGroup(of: CourseStructure?.self) { group in + + group.addTask { + try await self.interactor.getCourseBlocks(courseID: courseID) + } + + group.addTask { + try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000) + return nil + } + + guard let firstResult = try await group.next() else { + return nil + } + + group.cancelAll() + + return firstResult + } + } + @MainActor func getCourseStructure(courseID: String) async throws -> CourseStructure? { if isInternetAvaliable { - return try await interactor.getCourseBlocks(courseID: courseID) + if let test = try await getCourseBlocksWithTimeout(courseID: courseID, timeoutSeconds: 15) { + return test + } + + return try await interactor.getLoadedCourseBlocks(courseID: courseID) } else { return try await interactor.getLoadedCourseBlocks(courseID: courseID) } diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 2c2c8565e..8872771a2 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -67,7 +67,9 @@ public struct CourseOutlineView: View { collapsed: $collapsed, viewHeight: $viewHeight ) + RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) + VStack(alignment: .leading) { if isVideo, diff --git a/Podfile.lock b/Podfile.lock index ab2424669..92ba93fc7 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -36,4 +36,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 2d71ad797d49fa32b47c3315b92159de82824103 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 From cc0e655031941766bb2d8fc1c7d6c65e70d1b0bd Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 30 Jul 2025 11:10:56 +0300 Subject: [PATCH 02/24] fix: update adjustments --- .../Presentation/Container/CourseContainerViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index a8162b51d..5cfd87ead 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -225,7 +225,9 @@ public final class CourseContainerViewModel: BaseCourseViewModel { if let test = try await getCourseBlocksWithTimeout(courseID: courseID, timeoutSeconds: 15) { return test } - + connectivity.internetReachableSubject.send(.notReachable) + isShowProgress = false + isShowRefresh = false return try await interactor.getLoadedCourseBlocks(courseID: courseID) } else { return try await interactor.getLoadedCourseBlocks(courseID: courseID) From 84a215601746183af43bbd82d6390409ac8b7b65 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 30 Jul 2025 11:22:35 +0300 Subject: [PATCH 03/24] fix: add timeout constant --- .../Presentation/Container/CourseContainerViewModel.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 5cfd87ead..2496e1f52 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -109,6 +109,9 @@ public final class CourseContainerViewModel: BaseCourseViewModel { private let interactor: CourseInteractorProtocol private let authInteractor: AuthInteractorProtocol + + private let timeOutIntervalSeconds: UInt64 = 15 + let analytics: CourseAnalytics let coreAnalytics: CoreAnalytics private(set) var storage: CourseStorage @@ -222,7 +225,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { @MainActor func getCourseStructure(courseID: String) async throws -> CourseStructure? { if isInternetAvaliable { - if let test = try await getCourseBlocksWithTimeout(courseID: courseID, timeoutSeconds: 15) { + if let test = try await getCourseBlocksWithTimeout(courseID: courseID, timeoutSeconds: timeOutIntervalSeconds) { return test } connectivity.internetReachableSubject.send(.notReachable) From cd349b3f22fcb06008f2991bc895f926575d89ab Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 30 Jul 2025 11:30:59 +0300 Subject: [PATCH 04/24] fix: adjust naming --- .../Presentation/Container/CourseContainerViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 2496e1f52..74e40265b 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -225,8 +225,8 @@ public final class CourseContainerViewModel: BaseCourseViewModel { @MainActor func getCourseStructure(courseID: String) async throws -> CourseStructure? { if isInternetAvaliable { - if let test = try await getCourseBlocksWithTimeout(courseID: courseID, timeoutSeconds: timeOutIntervalSeconds) { - return test + if let courseStructure = try await getCourseBlocksWithTimeout(courseID: courseID, timeoutSeconds: timeOutIntervalSeconds) { + return courseStructure } connectivity.internetReachableSubject.send(.notReachable) isShowProgress = false From 5aa8e55f0cde4c6c6580338402fd2156ef5de2f9 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 30 Jul 2025 16:42:23 +0300 Subject: [PATCH 05/24] fix: update connectivity --- Core/Core/Configuration/Connectivity.swift | 110 +++++++++++++----- .../Container/CourseContainerViewModel.swift | 8 +- 2 files changed, 80 insertions(+), 38 deletions(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 864e10e9f..741791b19 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -1,9 +1,9 @@ -// -// Connectivity.swift -// OpenEdX -// -// Created by  Stepanok Ivan on 15.12.2022. -// +//// +//// Connectivity.swift +//// OpenEdX +//// +//// Created by  Stepanok Ivan on 15.12.2022. +//// import Alamofire import Combine @@ -24,13 +24,24 @@ public protocol ConnectivityProtocol: Sendable { @MainActor public class Connectivity: ConnectivityProtocol { - let networkManager = NetworkReachabilityManager() - - public var isInternetAvaliable: Bool { - // false - networkManager?.isReachable ?? false - } - + + private let networkManager = NetworkReachabilityManager() + private let verificationURL: URL + private let verificationTimeout: TimeInterval + + private static var lastVerificationDate: TimeInterval? + private static var lastVerificationResult: Bool = false + + private var _isInternetAvailable: Bool = true { + didSet { + internetReachableSubject.send(_isInternetAvailable ? .reachable : .notReachable) + } + } + + public var isInternetAvaliable: Bool { + return _isInternetAvailable + } + public var isMobileData: Bool { if let networkManager { return networkManager.isReachableOnCellular @@ -38,31 +49,68 @@ public class Connectivity: ConnectivityProtocol { return false } } - + public let internetReachableSubject = CurrentValueSubject(nil) - - public init() { + + public init( + urlToVerify: URL = Config().baseURL, + timeout: TimeInterval = 15 + ) { + self.verificationURL = urlToVerify + self.verificationTimeout = timeout checkInternet() } - - func checkInternet() { - if let networkManager { - networkManager.startListening { status in - DispatchQueue.main.async { - switch status { - case .unknown: - self.internetReachableSubject.send(InternetState.notReachable) - case .notReachable: - self.internetReachableSubject.send(InternetState.notReachable) - case .reachable: - self.internetReachableSubject.send(InternetState.reachable) + + deinit { + networkManager?.stopListening() + } + + private func checkInternet() { + guard let nm = networkManager else { + _isInternetAvailable = false + return + } + + nm.startListening { [weak self] status in + DispatchQueue.main.async { + guard let self = self else { return } + switch status { + case .reachable: + let nowTS = Date().timeIntervalSince1970 + if let lastTS = Connectivity.lastVerificationDate, + nowTS - lastTS < 30 { + self._isInternetAvailable = Connectivity.lastVerificationResult + } else { + Task { @MainActor in + let live = await self.verifyInternet() + Connectivity.lastVerificationDate = Date().timeIntervalSince1970 + Connectivity.lastVerificationResult = live + self._isInternetAvailable = live + } } + case .notReachable, .unknown: + Connectivity.lastVerificationDate = nil + Connectivity.lastVerificationResult = false + self._isInternetAvailable = false } } - } else { - DispatchQueue.main.async { - self.internetReachableSubject.send(InternetState.notReachable) + } + } + + private func verifyInternet() async -> Bool { + var request = URLRequest(url: verificationURL) + request.httpMethod = "HEAD" + request.timeoutInterval = verificationTimeout + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, + (200..<400).contains(http.statusCode) { + return true } + } catch { + return false } + return false } } diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 74e40265b..31950d661 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -225,13 +225,7 @@ public final class CourseContainerViewModel: BaseCourseViewModel { @MainActor func getCourseStructure(courseID: String) async throws -> CourseStructure? { if isInternetAvaliable { - if let courseStructure = try await getCourseBlocksWithTimeout(courseID: courseID, timeoutSeconds: timeOutIntervalSeconds) { - return courseStructure - } - connectivity.internetReachableSubject.send(.notReachable) - isShowProgress = false - isShowRefresh = false - return try await interactor.getLoadedCourseBlocks(courseID: courseID) + return try await self.interactor.getCourseBlocks(courseID: courseID) } else { return try await interactor.getLoadedCourseBlocks(courseID: courseID) } From 64c59a149887023fa90968c551b6001fc62ac838 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Thu, 31 Jul 2025 13:27:21 +0300 Subject: [PATCH 06/24] fix: remove unused functions and hardcoded variables --- Core/Core/Configuration/Connectivity.swift | 3 +- .../Container/CourseContainerViewModel.swift | 29 +------------------ 2 files changed, 3 insertions(+), 29 deletions(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 741791b19..0a1a58ff8 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -28,6 +28,7 @@ public class Connectivity: ConnectivityProtocol { private let networkManager = NetworkReachabilityManager() private let verificationURL: URL private let verificationTimeout: TimeInterval + private let secondsPast: TimeInterval = 30 private static var lastVerificationDate: TimeInterval? private static var lastVerificationResult: Bool = false @@ -78,7 +79,7 @@ public class Connectivity: ConnectivityProtocol { case .reachable: let nowTS = Date().timeIntervalSince1970 if let lastTS = Connectivity.lastVerificationDate, - nowTS - lastTS < 30 { + nowTS - lastTS < self.secondsPast { self._isInternetAvailable = Connectivity.lastVerificationResult } else { Task { @MainActor in diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 31950d661..c6aa68584 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -110,8 +110,6 @@ public final class CourseContainerViewModel: BaseCourseViewModel { private let interactor: CourseInteractorProtocol private let authInteractor: AuthInteractorProtocol - private let timeOutIntervalSeconds: UInt64 = 15 - let analytics: CourseAnalytics let coreAnalytics: CoreAnalytics private(set) var storage: CourseStorage @@ -197,35 +195,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { ) } - private func getCourseBlocksWithTimeout( - courseID: String, - timeoutSeconds: UInt64 - ) async throws -> CourseStructure? { - return try await withThrowingTaskGroup(of: CourseStructure?.self) { group in - - group.addTask { - try await self.interactor.getCourseBlocks(courseID: courseID) - } - - group.addTask { - try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000) - return nil - } - - guard let firstResult = try await group.next() else { - return nil - } - - group.cancelAll() - - return firstResult - } - } - @MainActor func getCourseStructure(courseID: String) async throws -> CourseStructure? { if isInternetAvaliable { - return try await self.interactor.getCourseBlocks(courseID: courseID) + return try await interactor.getCourseBlocks(courseID: courseID) } else { return try await interactor.getLoadedCourseBlocks(courseID: courseID) } From b4fcf31493cceb200b02b92ca9ca3f44ae639db4 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Thu, 31 Jul 2025 13:27:21 +0300 Subject: [PATCH 07/24] fix: Removed unused functions and hardcoded variables --- Core/Core/Configuration/Connectivity.swift | 3 +- .../Container/CourseContainerViewModel.swift | 29 +------------------ 2 files changed, 3 insertions(+), 29 deletions(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 741791b19..0a1a58ff8 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -28,6 +28,7 @@ public class Connectivity: ConnectivityProtocol { private let networkManager = NetworkReachabilityManager() private let verificationURL: URL private let verificationTimeout: TimeInterval + private let secondsPast: TimeInterval = 30 private static var lastVerificationDate: TimeInterval? private static var lastVerificationResult: Bool = false @@ -78,7 +79,7 @@ public class Connectivity: ConnectivityProtocol { case .reachable: let nowTS = Date().timeIntervalSince1970 if let lastTS = Connectivity.lastVerificationDate, - nowTS - lastTS < 30 { + nowTS - lastTS < self.secondsPast { self._isInternetAvailable = Connectivity.lastVerificationResult } else { Task { @MainActor in diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 31950d661..c6aa68584 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -110,8 +110,6 @@ public final class CourseContainerViewModel: BaseCourseViewModel { private let interactor: CourseInteractorProtocol private let authInteractor: AuthInteractorProtocol - private let timeOutIntervalSeconds: UInt64 = 15 - let analytics: CourseAnalytics let coreAnalytics: CoreAnalytics private(set) var storage: CourseStorage @@ -197,35 +195,10 @@ public final class CourseContainerViewModel: BaseCourseViewModel { ) } - private func getCourseBlocksWithTimeout( - courseID: String, - timeoutSeconds: UInt64 - ) async throws -> CourseStructure? { - return try await withThrowingTaskGroup(of: CourseStructure?.self) { group in - - group.addTask { - try await self.interactor.getCourseBlocks(courseID: courseID) - } - - group.addTask { - try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000) - return nil - } - - guard let firstResult = try await group.next() else { - return nil - } - - group.cancelAll() - - return firstResult - } - } - @MainActor func getCourseStructure(courseID: String) async throws -> CourseStructure? { if isInternetAvaliable { - return try await self.interactor.getCourseBlocks(courseID: courseID) + return try await interactor.getCourseBlocks(courseID: courseID) } else { return try await interactor.getLoadedCourseBlocks(courseID: courseID) } From 6121c2a63f27676232bf52b9f92559b6eb0dc841 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Thu, 31 Jul 2025 13:50:29 +0300 Subject: [PATCH 08/24] fix: issue 581 --- Core/Core/Configuration/Connectivity.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 0a1a58ff8..d3f879aed 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -29,7 +29,6 @@ public class Connectivity: ConnectivityProtocol { private let verificationURL: URL private let verificationTimeout: TimeInterval private let secondsPast: TimeInterval = 30 - private static var lastVerificationDate: TimeInterval? private static var lastVerificationResult: Bool = false From b26c3d7de4554b4dadeaa55e07f0426204859c9c Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Thu, 31 Jul 2025 15:15:30 +0300 Subject: [PATCH 09/24] fix: progress on issue 581 --- Core/Core/Configuration/Connectivity.swift | 87 ++++++++++--------- Core/Core/View/Base/OfflineSnackBarView.swift | 3 +- Core/Core/View/Base/WebBrowser.swift | 3 +- .../Container/CourseContainerView.swift | 4 +- .../Presentation/Dates/CourseDatesView.swift | 2 +- .../Presentation/Handouts/HandoutsView.swift | 2 +- .../Presentation/Offline/OfflineView.swift | 2 +- .../Subviews/LargestDownloadsView.swift | 2 +- .../Outline/CourseOutlineView.swift | 2 +- .../CourseVertical/CourseVerticalView.swift | 4 +- .../Subviews/CustomDisclosureGroup.swift | 2 +- .../Unit/CourseNavigationView.swift | 2 +- .../Presentation/Unit/CourseUnitView.swift | 2 +- .../Presentation/Unit/Subviews/WebView.swift | 2 +- .../Video/EncodedVideoPlayer.swift | 2 +- .../Presentation/Video/SubtitlesView.swift | 2 +- .../Video/YouTubeVideoPlayer.swift | 2 +- .../Presentation/AllCoursesView.swift | 2 +- .../Presentation/ListDashboardView.swift | 2 +- .../PrimaryCourseDashboardView.swift | 2 +- .../NativeDiscovery/CourseDetailsView.swift | 2 +- .../NativeDiscovery/DiscoveryView.swift | 2 +- .../NativeDiscovery/SearchView.swift | 2 +- .../WebPrograms/ProgramWebviewView.swift | 2 +- .../Presentation/AppDownloadsViewModel.swift | 2 +- OpenEdX/DI/AppAssembly.swift | 14 ++- .../DatesAndCalendar/CoursesToSyncView.swift | 2 +- .../DatesAndCalendarView.swift | 2 +- .../Elements/NewCalendarView.swift | 2 +- .../SyncCalendarOptionsView.swift | 2 +- .../DeleteAccount/DeleteAccountView.swift | 2 +- .../Presentation/Profile/ProfileView.swift | 5 +- .../Settings/ManageAccountView.swift | 6 +- .../Presentation/Settings/SettingsView.swift | 2 +- .../Settings/VideoQualityView.swift | 2 +- .../Settings/VideoSettingsView.swift | 2 +- .../Settings/SettingsViewModelTests.swift | 12 +-- 37 files changed, 107 insertions(+), 87 deletions(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index d3f879aed..2d1df1d34 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -1,9 +1,9 @@ -//// -//// Connectivity.swift -//// OpenEdX -//// -//// Created by  Stepanok Ivan on 15.12.2022. -//// +// +// Connectivity.swift +// OpenEdX +// +// Created by Stepanok Ivan on 15.12.2022. +// import Alamofire import Combine @@ -14,7 +14,7 @@ public enum InternetState: Sendable { case notReachable } -//sourcery: AutoMockable +// sourcery: AutoMockable @MainActor public protocol ConnectivityProtocol: Sendable { var isInternetAvaliable: Bool { get } @@ -29,69 +29,73 @@ public class Connectivity: ConnectivityProtocol { private let verificationURL: URL private let verificationTimeout: TimeInterval private let secondsPast: TimeInterval = 30 + private static var lastVerificationDate: TimeInterval? private static var lastVerificationResult: Bool = false private var _isInternetAvailable: Bool = true { - didSet { - internetReachableSubject.send(_isInternetAvailable ? .reachable : .notReachable) - } - } + didSet { + internetReachableSubject.send(_isInternetAvailable ? .reachable : .notReachable) + } + } - public var isInternetAvaliable: Bool { - return _isInternetAvailable - } + public var isInternetAvaliable: Bool { + _isInternetAvailable + } public var isMobileData: Bool { - if let networkManager { - return networkManager.isReachableOnCellular - } else { - return false - } + networkManager?.isReachableOnCellular == true } public let internetReachableSubject = CurrentValueSubject(nil) public init( - urlToVerify: URL = Config().baseURL, + config: ConfigProtocol, timeout: TimeInterval = 15 ) { - self.verificationURL = urlToVerify + print("+++ go") + self.verificationURL = config.baseURL self.verificationTimeout = timeout checkInternet() } deinit { + print("+++ deinit") networkManager?.stopListening() } - private func checkInternet() { - guard let nm = networkManager else { - _isInternetAvailable = false - return - } + @MainActor + private func updateAvailability( + _ available: Bool, + lastChecked: TimeInterval = Date().timeIntervalSince1970 + ) { + self._isInternetAvailable = available + Connectivity.lastVerificationDate = lastChecked + Connectivity.lastVerificationResult = available + } - nm.startListening { [weak self] status in - DispatchQueue.main.async { - guard let self = self else { return } + func checkInternet() { + networkManager?.startListening(onQueue: .global()) { [weak self] status in + guard let self = self else { return } + let now = Date().timeIntervalSince1970 + + Task { @MainActor in switch status { case .reachable: - let nowTS = Date().timeIntervalSince1970 - if let lastTS = Connectivity.lastVerificationDate, - nowTS - lastTS < self.secondsPast { - self._isInternetAvailable = Connectivity.lastVerificationResult + if let last = Connectivity.lastVerificationDate, + now - last < self.secondsPast { + print("+++ last") + self.updateAvailability(Connectivity.lastVerificationResult) } else { - Task { @MainActor in + Task.detached { + print("+++ verif") let live = await self.verifyInternet() - Connectivity.lastVerificationDate = Date().timeIntervalSince1970 - Connectivity.lastVerificationResult = live - self._isInternetAvailable = live + await self.updateAvailability(live, lastChecked: Date().timeIntervalSince1970) } } + case .notReachable, .unknown: - Connectivity.lastVerificationDate = nil - Connectivity.lastVerificationResult = false - self._isInternetAvailable = false + self.updateAvailability(false, lastChecked: 0) } } } @@ -106,11 +110,14 @@ public class Connectivity: ConnectivityProtocol { let (_, response) = try await URLSession.shared.data(for: request) if let http = response as? HTTPURLResponse, (200..<400).contains(http.statusCode) { + print("++++ got response") return true } } catch { + print("++++ no response") return false } + print("++++ no response") return false } } diff --git a/Core/Core/View/Base/OfflineSnackBarView.swift b/Core/Core/View/Base/OfflineSnackBarView.swift index c26a577ee..ad6c70130 100644 --- a/Core/Core/View/Base/OfflineSnackBarView.swift +++ b/Core/Core/View/Base/OfflineSnackBarView.swift @@ -79,6 +79,7 @@ public struct OfflineSnackBarView: View { struct OfflineSnackBarView_Previews: PreviewProvider { static var previews: some View { - OfflineSnackBarView(connectivity: Connectivity(), reloadAction: {}) + let configMock = ConfigMock() + OfflineSnackBarView(connectivity: Connectivity(config: configMock), reloadAction: {}) } } diff --git a/Core/Core/View/Base/WebBrowser.swift b/Core/Core/View/Base/WebBrowser.swift index cd61dcdb6..6ae177cb4 100644 --- a/Core/Core/View/Base/WebBrowser.swift +++ b/Core/Core/View/Base/WebBrowser.swift @@ -81,6 +81,7 @@ public struct WebBrowser: View { struct WebBrowser_Previews: PreviewProvider { static var previews: some View { - WebBrowser(url: "", pageTitle: "", connectivity: Connectivity()) + let configMock = ConfigMock() + WebBrowser(url: "", pageTitle: "", connectivity: Connectivity(config: configMock)) } } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index cc2bdc9e2..8db22ae10 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -366,7 +366,7 @@ struct CourseScreensView_Previews: PreviewProvider { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), config: ConfigMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), manager: DownloadManagerMock(), storage: CourseStorageMock(), isActive: true, @@ -382,7 +382,7 @@ struct CourseScreensView_Previews: PreviewProvider { interactor: CourseInteractor.mock, router: CourseRouterMock(), cssInjector: CSSInjectorMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), config: ConfigMock(), courseID: "1", courseName: "a", diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 192b7c2a9..5de224b44 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -196,7 +196,7 @@ struct CourseDatesView_Previews: PreviewProvider { interactor: CourseInteractor(repository: CourseRepositoryMock()), router: CourseRouterMock(), cssInjector: CSSInjectorMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), config: ConfigMock(), courseID: "", courseName: "", diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index d04be11af..82d74a408 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -123,7 +123,7 @@ struct HandoutsView_Previews: PreviewProvider { let viewModel = HandoutsViewModel(interactor: CourseInteractor.mock, router: CourseRouterMock(), cssInjector: CSSInjectorMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), courseID: "", analytics: CourseAnalyticsMock()) HandoutsView( diff --git a/Course/Course/Presentation/Offline/OfflineView.swift b/Course/Course/Presentation/Offline/OfflineView.swift index 9b5cee5ed..168e9e574 100644 --- a/Course/Course/Presentation/Offline/OfflineView.swift +++ b/Course/Course/Presentation/Offline/OfflineView.swift @@ -249,7 +249,7 @@ struct OfflineView: View { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), config: ConfigMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), manager: DownloadManagerMock(), storage: CourseStorageMock(), isActive: true, diff --git a/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift b/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift index 7aa0930d7..3c13cd278 100644 --- a/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift +++ b/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift @@ -93,7 +93,7 @@ struct LargestDownloadsView_Previews: PreviewProvider { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), config: ConfigMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), manager: DownloadManagerMock(), storage: CourseStorageMock(), isActive: true, diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 8872771a2..4049276ff 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -336,7 +336,7 @@ struct CourseOutlineView_Previews: PreviewProvider { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), config: ConfigMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), manager: DownloadManagerMock(), storage: CourseStorageMock(), isActive: true, diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index ed7b60af4..364e9c00a 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -181,9 +181,9 @@ struct CourseVerticalView_Previews: PreviewProvider { sequentialIndex: 0, router: CourseRouterMock(), analytics: CourseAnalyticsMock(), - connectivity: Connectivity() + connectivity: Connectivity(config: ConfigMock()), ) - + return Group { CourseVerticalView( title: "Course title", diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index ff252a654..edba4603e 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -399,7 +399,7 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), config: ConfigMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), manager: DownloadManagerMock(), storage: CourseStorageMock(), isActive: true, diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index 4257f18e9..4047f81c7 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -160,7 +160,7 @@ struct CourseNavigationView_Previews: PreviewProvider { config: ConfigMock(), router: CourseRouterMock(), analytics: CourseAnalyticsMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), storage: CourseStorageMock(), manager: DownloadManagerMock() ) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 2588d7369..2725afd2e 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -723,7 +723,7 @@ struct CourseUnitView_Previews: PreviewProvider { config: ConfigMock(), router: CourseRouterMock(), analytics: CourseAnalyticsMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), storage: CourseStorageMock(), manager: DownloadManagerMock() )) diff --git a/Course/Course/Presentation/Unit/Subviews/WebView.swift b/Course/Course/Presentation/Unit/Subviews/WebView.swift index b8823bda6..935884a28 100644 --- a/Course/Course/Presentation/Unit/Subviews/WebView.swift +++ b/Course/Course/Presentation/Unit/Subviews/WebView.swift @@ -23,7 +23,7 @@ struct WebView: View { url: url, dataUrl: localUrl, viewModel: Container.shared.resolve(WebUnitViewModel.self)!, - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), injections: injections, blockID: blockID ) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index a2d69340a..52e2428f1 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -145,7 +145,7 @@ struct EncodedVideoPlayer_Previews: PreviewProvider { viewModel: EncodedVideoPlayerViewModel( languages: [], playerStateSubject: CurrentValueSubject(nil), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), playerHolder: PlayerViewControllerHolder.mock, appStorage: CoreStorageMock(), analytics: CourseAnalyticsMock() diff --git a/Course/Course/Presentation/Video/SubtitlesView.swift b/Course/Course/Presentation/Video/SubtitlesView.swift index 5f7efc8a6..1680f2191 100644 --- a/Course/Course/Presentation/Video/SubtitlesView.swift +++ b/Course/Course/Presentation/Video/SubtitlesView.swift @@ -124,7 +124,7 @@ struct SubtittlesView_Previews: PreviewProvider { viewModel: VideoPlayerViewModel( languages: [], playerStateSubject: CurrentValueSubject(nil), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), playerHolder: PlayerViewControllerHolder.mock, appStorage: CoreStorageMock(), analytics: CourseAnalyticsMock() diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 5914a1807..8f5e2d14f 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -88,7 +88,7 @@ struct YouTubeVideoPlayer_Previews: PreviewProvider { viewModel: YouTubeVideoPlayerViewModel( languages: [], playerStateSubject: CurrentValueSubject(nil), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), playerHolder: YoutubePlayerViewControllerHolder.mock, appStorage: CoreStorageMock(), analytics: CourseAnalyticsMock() diff --git a/Dashboard/Dashboard/Presentation/AllCoursesView.swift b/Dashboard/Dashboard/Presentation/AllCoursesView.swift index 4ab5fd905..8b8e77acc 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesView.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesView.swift @@ -205,7 +205,7 @@ struct AllCoursesView_Previews: PreviewProvider { static var previews: some View { let vm = AllCoursesViewModel( interactor: DashboardInteractor.mock, - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), analytics: DashboardAnalyticsMock(), storage: CoreStorageMock() ) diff --git a/Dashboard/Dashboard/Presentation/ListDashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift index 8f668dcc5..ac397f6d6 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -163,7 +163,7 @@ struct ListDashboardView_Previews: PreviewProvider { static var previews: some View { let vm = ListDashboardViewModel( interactor: DashboardInteractor.mock, - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), analytics: DashboardAnalyticsMock(), storage: CoreStorageMock() ) diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift index 905dcaaae..e0f6eff05 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift @@ -334,7 +334,7 @@ struct PrimaryCourseDashboardView_Previews: PreviewProvider { static var previews: some View { let vm = PrimaryCourseDashboardViewModel( interactor: DashboardInteractor.mock, - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), analytics: DashboardAnalyticsMock(), config: ConfigMock(), storage: CoreStorageMock(), diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 2cbe294c1..fe1c32657 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -439,7 +439,7 @@ struct CourseDetailsView_Previews: PreviewProvider { analytics: DiscoveryAnalyticsMock(), config: ConfigMock(), cssInjector: CSSInjectorMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), storage: CoreStorageMock() ) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index ffeb3451b..cafe26adb 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -211,7 +211,7 @@ struct DiscoveryView_Previews: PreviewProvider { let vm = DiscoveryViewModel(router: DiscoveryRouterMock(), config: ConfigMock(), interactor: DiscoveryInteractor.mock, - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), analytics: DiscoveryAnalyticsMock(), storage: CoreStorageMock()) let router = DiscoveryRouterMock() diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index c059dbcba..7c565bfad 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -222,7 +222,7 @@ struct SearchView_Previews: PreviewProvider { let router = DiscoveryRouterMock() let vm = SearchViewModel( interactor: DiscoveryInteractor.mock, - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), router: router, analytics: DiscoveryAnalyticsMock(), storage: CoreStorageMock(), diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift index a206303d8..dbfc60403 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -139,7 +139,7 @@ struct ProgramWebviewView_Previews: PreviewProvider { router: DiscoveryRouterMock(), config: ConfigMock(), interactor: DiscoveryInteractor.mock, - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), analytics: DiscoveryAnalyticsMock(), authInteractor: AuthInteractor.mock ), diff --git a/Downloads/Downloads/Presentation/AppDownloadsViewModel.swift b/Downloads/Downloads/Presentation/AppDownloadsViewModel.swift index 104efc6f7..79696e030 100644 --- a/Downloads/Downloads/Presentation/AppDownloadsViewModel.swift +++ b/Downloads/Downloads/Presentation/AppDownloadsViewModel.swift @@ -779,7 +779,7 @@ public extension AppDownloadsViewModel { interactor: DownloadsInteractor.mock, courseManager: CourseStructureManagerMock(), downloadManager: DownloadManagerMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), downloadsHelper: DownloadsHelperMock(), router: DownloadsRouterMock(), storage: DownloadsStorageMock(), diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index f90b6a500..840cce6d5 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -87,8 +87,8 @@ class AppAssembly: Assembly { r.resolve(AnalyticsManager.self)! }.inObjectScope(.container) - container.register(ConnectivityProtocol.self) { @MainActor _ in - Connectivity() + container.register(ConnectivityProtocol.self) { @MainActor r in + Connectivity(config: r.resolve(ConfigProtocol.self)!) } container.register(DatabaseManager.self) { _ in @@ -193,7 +193,7 @@ class AppAssembly: Assembly { keychain: r.resolve(KeychainSwift.self)! ) } - + container.register(Validator.self) { _ in Validator() }.inObjectScope(.container) @@ -235,6 +235,14 @@ class AppAssembly: Assembly { courseDropDownNavigationEnabled: config.uiComponents.courseDropDownNavigationEnabled ) }.inObjectScope(.container) + + container.register(ConnectivityProtocol.self) { @MainActor r in + Connectivity( + config: r.resolve(ConfigProtocol.self)!, + timeout: 15 + ) + } + .inObjectScope(.container) } } // swiftlint:enable function_body_length diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift index ede274a85..e0791ac31 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift @@ -158,7 +158,7 @@ struct CoursesToSyncView_Previews: PreviewProvider { profileStorage: ProfileStorageMock(), persistence: ProfilePersistenceMock(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity() + connectivity: Connectivity(config: ConfigMock()), ) return CoursesToSyncView(viewModel: vm) .previewDisplayName("Courses to Sync") diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift index 52ef209f9..3993b8580 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift @@ -188,7 +188,7 @@ struct DatesAndCalendarView_Previews: PreviewProvider { profileStorage: ProfileStorageMock(), persistence: ProfilePersistenceMock(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity() + connectivity: Connectivity(config: ConfigMock()), ) DatesAndCalendarView(viewModel: vm) .loadFonts() diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift index 9d8b9dd70..ed4e27b8a 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift @@ -167,7 +167,7 @@ struct NewCalendarView: View { profileStorage: ProfileStorageMock(), persistence: ProfilePersistenceMock(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity() + connectivity: Connectivity(config: ConfigMock()) ), beginSyncingTapped: { }, diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift index b5aa1e691..e96a0cc1b 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -271,7 +271,7 @@ struct SyncCalendarOptionsView_Previews: PreviewProvider { profileStorage: ProfileStorageMock(), persistence: ProfilePersistenceMock(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity() + connectivity: Connectivity(config: ConfigMock()), ) SyncCalendarOptionsView(viewModel: vm) .loadFonts() diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 585c06279..9b2ad9525 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -188,7 +188,7 @@ struct DeleteAccountView_Previews: PreviewProvider { let vm = DeleteAccountViewModel( interactor: ProfileInteractor.mock, router: router, - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), analytics: ProfileAnalyticsMock() ) diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 1691afc3d..b9d49b4b8 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -189,12 +189,13 @@ public struct ProfileView: View { struct ProfileView_Previews: PreviewProvider { static var previews: some View { let router = ProfileRouterMock() + let config = ConfigMock() let vm = ProfileViewModel( interactor: ProfileInteractor.mock, router: router, analytics: ProfileAnalyticsMock(), - config: ConfigMock(), - connectivity: Connectivity() + config: config, + connectivity: Connectivity(config: config), ) ProfileView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/ManageAccountView.swift b/Profile/Profile/Presentation/Settings/ManageAccountView.swift index 8b791d42b..154eddbcf 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountView.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountView.swift @@ -201,11 +201,13 @@ public struct ManageAccountView: View { struct ManageAccountView_Previews: PreviewProvider { static var previews: some View { let router = ProfileRouterMock() + let configMock = ConfigMock() + let vm = ManageAccountViewModel( router: router, analytics: ProfileAnalyticsMock(), - config: ConfigMock(), - connectivity: Connectivity(), + config: configMock, + connectivity: Connectivity(config: configMock), interactor: ProfileInteractor.mock ) diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index e58533f4d..b1410b656 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -254,7 +254,7 @@ public struct SettingsView: View { coreAnalytics: CoreAnalyticsMock(), config: ConfigMock(), corePersistence: CorePersistenceMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), coreStorage: CoreStorageMock() ) diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index d06c1ea9f..8f662976d 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -134,7 +134,7 @@ public struct VideoQualityView: View { coreAnalytics: CoreAnalyticsMock(), config: ConfigMock(), corePersistence: CorePersistenceMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), coreStorage: CoreStorageMock() ) diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index 3e4fd4b4c..a8c29bcc0 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -141,7 +141,7 @@ public struct VideoSettingsView: View { coreAnalytics: CoreAnalyticsMock(), config: ConfigMock(), corePersistence: CorePersistenceMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), coreStorage: CoreStorageMock() ) diff --git a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift index d9e80e66f..3455d8814 100644 --- a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift @@ -44,7 +44,7 @@ final class SettingsViewModelTests: XCTestCase { coreAnalytics: coreAnalytics, config: ConfigMock(), corePersistence: CorePersistenceMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), coreStorage: storage ) @@ -82,7 +82,7 @@ final class SettingsViewModelTests: XCTestCase { coreAnalytics: coreAnalytics, config: ConfigMock(), corePersistence: CorePersistenceMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), coreStorage: storage ) @@ -119,7 +119,7 @@ final class SettingsViewModelTests: XCTestCase { coreAnalytics: coreAnalytics, config: ConfigMock(), corePersistence: CorePersistenceMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), coreStorage: storage ) @@ -156,7 +156,7 @@ final class SettingsViewModelTests: XCTestCase { coreAnalytics: coreAnalytics, config: ConfigMock(), corePersistence: CorePersistenceMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), coreStorage: storage ) @@ -193,7 +193,7 @@ final class SettingsViewModelTests: XCTestCase { coreAnalytics: coreAnalytics, config: ConfigMock(), corePersistence: CorePersistenceMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), coreStorage: storage ) @@ -230,7 +230,7 @@ final class SettingsViewModelTests: XCTestCase { coreAnalytics: coreAnalytics, config: ConfigMock(), corePersistence: CorePersistenceMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), coreStorage: storage ) From f21a460b481ccd60200686f66e8007ef67bcbc80 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Thu, 31 Jul 2025 15:47:07 +0300 Subject: [PATCH 10/24] fix: apply updates --- Core/Core/Configuration/Connectivity.swift | 7 ------- OpenEdX/DI/AppAssembly.swift | 12 ++++++------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 2d1df1d34..25ecf63e6 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -53,14 +53,12 @@ public class Connectivity: ConnectivityProtocol { config: ConfigProtocol, timeout: TimeInterval = 15 ) { - print("+++ go") self.verificationURL = config.baseURL self.verificationTimeout = timeout checkInternet() } deinit { - print("+++ deinit") networkManager?.stopListening() } @@ -84,11 +82,9 @@ public class Connectivity: ConnectivityProtocol { case .reachable: if let last = Connectivity.lastVerificationDate, now - last < self.secondsPast { - print("+++ last") self.updateAvailability(Connectivity.lastVerificationResult) } else { Task.detached { - print("+++ verif") let live = await self.verifyInternet() await self.updateAvailability(live, lastChecked: Date().timeIntervalSince1970) } @@ -110,14 +106,11 @@ public class Connectivity: ConnectivityProtocol { let (_, response) = try await URLSession.shared.data(for: request) if let http = response as? HTTPURLResponse, (200..<400).contains(http.statusCode) { - print("++++ got response") return true } } catch { - print("++++ no response") return false } - print("++++ no response") return false } } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 840cce6d5..9dc64cf92 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -86,11 +86,12 @@ class AppAssembly: Assembly { container.register(DownloadsAnalytics.self) { r in r.resolve(AnalyticsManager.self)! }.inObjectScope(.container) - - container.register(ConnectivityProtocol.self) { @MainActor r in - Connectivity(config: r.resolve(ConfigProtocol.self)!) - } - + +// container.register(ConnectivityProtocol.self) { @MainActor r in +// Connectivity(config: r.resolve(ConfigProtocol.self)!) +// } +// + container.register(DatabaseManager.self) { _ in DatabaseManager(databaseName: "Database") }.inObjectScope(.container) @@ -242,7 +243,6 @@ class AppAssembly: Assembly { timeout: 15 ) } - .inObjectScope(.container) } } // swiftlint:enable function_body_length From 327bb30268435e0cb10e10308fcee25426ae0ff3 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Thu, 31 Jul 2025 18:37:53 +0300 Subject: [PATCH 11/24] fix: connectivity logic update --- Core/Core/Configuration/Connectivity.swift | 80 ++++++++++------------ OpenEdX/DI/AppAssembly.swift | 7 +- 2 files changed, 39 insertions(+), 48 deletions(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 25ecf63e6..4349f4d0c 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -22,90 +22,86 @@ public protocol ConnectivityProtocol: Sendable { var internetReachableSubject: CurrentValueSubject { get } } -@MainActor public class Connectivity: ConnectivityProtocol { private let networkManager = NetworkReachabilityManager() private let verificationURL: URL private let verificationTimeout: TimeInterval - private let secondsPast: TimeInterval = 30 + private let cacheValidity: TimeInterval = 5//30 + + private var lastVerificationDate: TimeInterval? + private var lastVerificationResult: Bool = true - private static var lastVerificationDate: TimeInterval? - private static var lastVerificationResult: Bool = false + public let internetReachableSubject = CurrentValueSubject(nil) - private var _isInternetAvailable: Bool = true { + private(set) var _isInternetAvailable: Bool = true { didSet { - internetReachableSubject.send(_isInternetAvailable ? .reachable : .notReachable) + Task { @MainActor in + internetReachableSubject.send(_isInternetAvailable ? .reachable : .notReachable) + } } } public var isInternetAvaliable: Bool { - _isInternetAvailable + if let last = lastVerificationDate, + Date().timeIntervalSince1970 - last < cacheValidity { + return lastVerificationResult + } + + Task { + await performVerification() + } + + return lastVerificationResult } public var isMobileData: Bool { networkManager?.isReachableOnCellular == true } - public let internetReachableSubject = CurrentValueSubject(nil) - public init( config: ConfigProtocol, timeout: TimeInterval = 15 ) { self.verificationURL = config.baseURL self.verificationTimeout = timeout - checkInternet() - } - - deinit { - networkManager?.stopListening() - } - - @MainActor - private func updateAvailability( - _ available: Bool, - lastChecked: TimeInterval = Date().timeIntervalSince1970 - ) { - self._isInternetAvailable = available - Connectivity.lastVerificationDate = lastChecked - Connectivity.lastVerificationResult = available - } - func checkInternet() { networkManager?.startListening(onQueue: .global()) { [weak self] status in guard let self = self else { return } - let now = Date().timeIntervalSince1970 - Task { @MainActor in switch status { case .reachable: - if let last = Connectivity.lastVerificationDate, - now - last < self.secondsPast { - self.updateAvailability(Connectivity.lastVerificationResult) - } else { - Task.detached { - let live = await self.verifyInternet() - await self.updateAvailability(live, lastChecked: Date().timeIntervalSince1970) - } - } - + await self.performVerification() case .notReachable, .unknown: - self.updateAvailability(false, lastChecked: 0) + self.updateAvailability(false, at: 0) } } } } + deinit { + networkManager?.stopListening() + } + + private func performVerification() async { + let now = Date().timeIntervalSince1970 + let live = await verifyInternet() + updateAvailability(live, at: now) + } + + private func updateAvailability(_ available: Bool, at timestamp: TimeInterval) { + _isInternetAvailable = available + lastVerificationDate = timestamp + lastVerificationResult = available + } + private func verifyInternet() async -> Bool { var request = URLRequest(url: verificationURL) request.httpMethod = "HEAD" request.timeoutInterval = verificationTimeout - do { let (_, response) = try await URLSession.shared.data(for: request) - if let http = response as? HTTPURLResponse, - (200..<400).contains(http.statusCode) { + if let http = response as? HTTPURLResponse, (200..<400).contains(http.statusCode) { return true } } catch { diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 9dc64cf92..93bb9cb52 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -87,11 +87,6 @@ class AppAssembly: Assembly { r.resolve(AnalyticsManager.self)! }.inObjectScope(.container) -// container.register(ConnectivityProtocol.self) { @MainActor r in -// Connectivity(config: r.resolve(ConfigProtocol.self)!) -// } -// - container.register(DatabaseManager.self) { _ in DatabaseManager(databaseName: "Database") }.inObjectScope(.container) @@ -242,7 +237,7 @@ class AppAssembly: Assembly { config: r.resolve(ConfigProtocol.self)!, timeout: 15 ) - } + }.inObjectScope(.container) } } // swiftlint:enable function_body_length From a9074d0ef59cb935ebcc97d5da9da21340d49fc9 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Thu, 31 Jul 2025 18:39:29 +0300 Subject: [PATCH 12/24] fix: cacheValidity change --- Core/Core/Configuration/Connectivity.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 4349f4d0c..1168c41e1 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -27,7 +27,7 @@ public class Connectivity: ConnectivityProtocol { private let networkManager = NetworkReachabilityManager() private let verificationURL: URL private let verificationTimeout: TimeInterval - private let cacheValidity: TimeInterval = 5//30 + private let cacheValidity: TimeInterval = 30 private var lastVerificationDate: TimeInterval? private var lastVerificationResult: Bool = true From dabc7441331e29fefebb89e91370ee1cbe74ed51 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 6 Aug 2025 15:44:53 +0300 Subject: [PATCH 13/24] fix: unit tests update --- .../DiscoveryViewModelTests.swift | 8 +-- .../Presentation/SearchViewModelTests.swift | 8 +-- OpenEdX.xcodeproj/project.pbxproj | 60 +++++++++---------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift index f9918498d..8b9beb780 100644 --- a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift @@ -25,7 +25,7 @@ final class DiscoveryViewModelTests: XCTestCase { func testGetDiscoveryCourses() async throws { let interactor = DiscoveryInteractorProtocolMock() - let connectivity = Connectivity() + let connectivity = Connectivity(config: ConfigMock()) let analytics = DiscoveryAnalyticsMock() let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), config: ConfigMock(), @@ -81,7 +81,7 @@ final class DiscoveryViewModelTests: XCTestCase { func testDiscoverySuccess() async throws { let interactor = DiscoveryInteractorProtocolMock() - let connectivity = Connectivity() + let connectivity = Connectivity(config: ConfigMock()) let analytics = DiscoveryAnalyticsMock() let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), config: ConfigMock(), @@ -191,7 +191,7 @@ final class DiscoveryViewModelTests: XCTestCase { func testDiscoveryNoInternetError() async throws { let interactor = DiscoveryInteractorProtocolMock() - let connectivity = Connectivity() + let connectivity = Connectivity(config: ConfigMock()) let analytics = DiscoveryAnalyticsMock() let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), config: ConfigMock(), @@ -215,7 +215,7 @@ final class DiscoveryViewModelTests: XCTestCase { func testDiscoveryUnknownError() async throws { let interactor = DiscoveryInteractorProtocolMock() - let connectivity = Connectivity() + let connectivity = Connectivity(config: ConfigMock()) let analytics = DiscoveryAnalyticsMock() let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), config: ConfigMock(), diff --git a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift index a1851efcc..d837f982c 100644 --- a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift @@ -25,7 +25,7 @@ final class SearchViewModelTests: XCTestCase { func testSearchSuccess() async throws { let interactor = DiscoveryInteractorProtocolMock() - let connectivity = Connectivity() + let connectivity = Connectivity(config: ConfigMock()) let analytics = DiscoveryAnalyticsMock() let router = DiscoveryRouterMock() let viewModel = SearchViewModel( @@ -87,7 +87,7 @@ final class SearchViewModelTests: XCTestCase { func testSearchEmptyQuerySuccess() async throws { let interactor = DiscoveryInteractorProtocolMock() - let connectivity = Connectivity() + let connectivity = Connectivity(config: ConfigMock()) let analytics = DiscoveryAnalyticsMock() let router = DiscoveryRouterMock() let viewModel = SearchViewModel( @@ -111,7 +111,7 @@ final class SearchViewModelTests: XCTestCase { func testSearchNoInternetError() async throws { let interactor = DiscoveryInteractorProtocolMock() - let connectivity = Connectivity() + let connectivity = Connectivity(config: ConfigMock()) let analytics = DiscoveryAnalyticsMock() let router = DiscoveryRouterMock() let viewModel = SearchViewModel( @@ -143,7 +143,7 @@ final class SearchViewModelTests: XCTestCase { func testSearchUnknownError() async throws { let interactor = DiscoveryInteractorProtocolMock() - let connectivity = Connectivity() + let connectivity = Connectivity(config: ConfigMock()) let analytics = DiscoveryAnalyticsMock() let router = DiscoveryRouterMock() let viewModel = SearchViewModel( diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 0faa4d76e..bc4acd68d 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -47,7 +47,7 @@ 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */; }; 07D5DA3E28D075AB00752FD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */; }; 149FF39E2B9F1AB50034B33F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 149FF39C2B9F1AB50034B33F /* LaunchScreen.storyboard */; }; - 3ACA3A1E886F3F9B2735B9AF /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D27E034A83DDEBF18D53B04 /* Pods_App_OpenEdX.framework */; }; + 705A908842AAAFC361CD9D52 /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58FAA9E3ECC93D0E638D877D /* Pods_App_OpenEdX.framework */; }; A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668A2B613ED10024680B /* PushNotificationsManager.swift */; }; A500668D2B6143000024680B /* FCMProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668C2B6143000024680B /* FCMProvider.swift */; }; A50066932B614DCD0024680B /* FCMListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066922B614DCD0024680B /* FCMListener.swift */; }; @@ -95,7 +95,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 005D1F4D92679D24B3BAA8FE /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; 020CA5D82AA0A25300970AAF /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = ""; }; 0218196328F734FA00202564 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0219C67628F4347600D64452 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -136,9 +135,12 @@ 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 149FF39D2B9F1AB50034B33F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 3D27E034A83DDEBF18D53B04 /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 4449C4D4F119C87B452DDFCD /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; - 8A45E2C9AF0CBE70A09FB37B /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; + 23F5E05C0D7EC044B0C9E719 /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; + 2C04239322282B0E6963D56B /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; + 58FAA9E3ECC93D0E638D877D /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6EAAEFD45AC766684492B1F7 /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; + 84185F0B853BA4F0A8C0217C /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; + 8A39EAD8663E6F16A59AF82E /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; A500668A2B613ED10024680B /* PushNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsManager.swift; sourceTree = ""; }; A500668C2B6143000024680B /* FCMProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMProvider.swift; sourceTree = ""; }; A50066922B614DCD0024680B /* FCMListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMListener.swift; sourceTree = ""; }; @@ -147,14 +149,12 @@ A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; BA7468752B96201D00793145 /* DeepLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouter.swift; sourceTree = ""; }; - CCE1E0F850D3E25C0D6C6702 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; CE1D5B7A2CE60E000019CA34 /* ContainerMainActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerMainActor.swift; sourceTree = ""; }; CE3BD14D2CBEB0DA0026F4E3 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; CEB36E512D6A29CE00907A89 /* Downloads.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Downloads.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CEE5EDED2D6E0A290089F67C /* DownloadsPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsPersistence.swift; sourceTree = ""; }; - D70D30110012B7D52D05E876 /* Pods-App-OpenEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugprod.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - FD0CE8D22B755B4003A113BB /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; + F6C9DA21C55F17F9F1F1D9A1 /* Pods-App-OpenEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugprod.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -176,7 +176,7 @@ 0219C67728F4347600D64452 /* Course.framework in Frameworks */, CEBA52772CEBB69100619E2B /* OEXFirebaseAnalytics in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, - 3ACA3A1E886F3F9B2735B9AF /* Pods_App_OpenEdX.framework in Frameworks */, + 705A908842AAAFC361CD9D52 /* Pods_App_OpenEdX.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -282,7 +282,7 @@ 072787B028D34D83002E9142 /* Discovery.framework */, 0770DE4A28D0A462006D8A5D /* Authorization.framework */, 0770DE1228D07845006D8A5D /* Core.framework */, - 3D27E034A83DDEBF18D53B04 /* Pods_App_OpenEdX.framework */, + 58FAA9E3ECC93D0E638D877D /* Pods_App_OpenEdX.framework */, ); name = Frameworks; sourceTree = ""; @@ -290,12 +290,12 @@ 55A895025FB07897BA68E063 /* Pods */ = { isa = PBXGroup; children = ( - D70D30110012B7D52D05E876 /* Pods-App-OpenEdX.debugprod.xcconfig */, - 8A45E2C9AF0CBE70A09FB37B /* Pods-App-OpenEdX.debugstage.xcconfig */, - CCE1E0F850D3E25C0D6C6702 /* Pods-App-OpenEdX.debugdev.xcconfig */, - 4449C4D4F119C87B452DDFCD /* Pods-App-OpenEdX.releaseprod.xcconfig */, - FD0CE8D22B755B4003A113BB /* Pods-App-OpenEdX.releasestage.xcconfig */, - 005D1F4D92679D24B3BAA8FE /* Pods-App-OpenEdX.releasedev.xcconfig */, + F6C9DA21C55F17F9F1F1D9A1 /* Pods-App-OpenEdX.debugprod.xcconfig */, + 23F5E05C0D7EC044B0C9E719 /* Pods-App-OpenEdX.debugstage.xcconfig */, + 2C04239322282B0E6963D56B /* Pods-App-OpenEdX.debugdev.xcconfig */, + 84185F0B853BA4F0A8C0217C /* Pods-App-OpenEdX.releaseprod.xcconfig */, + 6EAAEFD45AC766684492B1F7 /* Pods-App-OpenEdX.releasestage.xcconfig */, + 8A39EAD8663E6F16A59AF82E /* Pods-App-OpenEdX.releasedev.xcconfig */, ); path = Pods; sourceTree = ""; @@ -390,7 +390,7 @@ isa = PBXNativeTarget; buildConfigurationList = 07D5DA4528D075AB00752FD9 /* Build configuration list for PBXNativeTarget "OpenEdX" */; buildPhases = ( - B2F937DF587D9697AF13B3F9 /* [CP] Check Pods Manifest.lock */, + 8389A0AC71F15AC25A0DF8E0 /* [CP] Check Pods Manifest.lock */, 0770DE2328D08647006D8A5D /* SwiftLint */, 07D5DA2D28D075AA00752FD9 /* Sources */, 07D5DA2E28D075AA00752FD9 /* Frameworks */, @@ -511,7 +511,7 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; }; - B2F937DF587D9697AF13B3F9 /* [CP] Check Pods Manifest.lock */ = { + 8389A0AC71F15AC25A0DF8E0 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -694,14 +694,14 @@ }; 02DD1C9629E80CC200F35DCE /* DebugStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8A45E2C9AF0CBE70A09FB37B /* Pods-App-OpenEdX.debugstage.xcconfig */; + baseConfigurationReference = 23F5E05C0D7EC044B0C9E719 /* Pods-App-OpenEdX.debugstage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L8PG7LC3Y3; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -786,14 +786,14 @@ }; 02DD1C9829E80CCB00F35DCE /* ReleaseStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FD0CE8D22B755B4003A113BB /* Pods-App-OpenEdX.releasestage.xcconfig */; + baseConfigurationReference = 6EAAEFD45AC766684492B1F7 /* Pods-App-OpenEdX.releasestage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L8PG7LC3Y3; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -884,14 +884,14 @@ }; 0727875928D231FD002E9142 /* DebugDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = CCE1E0F850D3E25C0D6C6702 /* Pods-App-OpenEdX.debugdev.xcconfig */; + baseConfigurationReference = 2C04239322282B0E6963D56B /* Pods-App-OpenEdX.debugdev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L8PG7LC3Y3; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -976,14 +976,14 @@ }; 0727875B28D23204002E9142 /* ReleaseDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 005D1F4D92679D24B3BAA8FE /* Pods-App-OpenEdX.releasedev.xcconfig */; + baseConfigurationReference = 8A39EAD8663E6F16A59AF82E /* Pods-App-OpenEdX.releasedev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L8PG7LC3Y3; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -1128,14 +1128,14 @@ }; 07D5DA4628D075AB00752FD9 /* DebugProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D70D30110012B7D52D05E876 /* Pods-App-OpenEdX.debugprod.xcconfig */; + baseConfigurationReference = F6C9DA21C55F17F9F1F1D9A1 /* Pods-App-OpenEdX.debugprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L8PG7LC3Y3; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -1166,14 +1166,14 @@ }; 07D5DA4728D075AB00752FD9 /* ReleaseProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4449C4D4F119C87B452DDFCD /* Pods-App-OpenEdX.releaseprod.xcconfig */; + baseConfigurationReference = 84185F0B853BA4F0A8C0217C /* Pods-App-OpenEdX.releaseprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L8PG7LC3Y3; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; From 48d55bc69d0db8ea0b73c210482cbb320df002cb Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 6 Aug 2025 15:59:55 +0300 Subject: [PATCH 14/24] fix: removed team and coma --- OpenEdX.xcodeproj/project.pbxproj | 12 ++++++------ .../Profile/Presentation/Profile/ProfileView.swift | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index bc4acd68d..7cd19c72b 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -701,7 +701,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -793,7 +793,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -891,7 +891,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -983,7 +983,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -1135,7 +1135,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -1173,7 +1173,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index b9d49b4b8..b1c2e6c6d 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -195,9 +195,9 @@ struct ProfileView_Previews: PreviewProvider { router: router, analytics: ProfileAnalyticsMock(), config: config, - connectivity: Connectivity(config: config), + connectivity: Connectivity(config: config) ) - + ProfileView(viewModel: vm) .preferredColorScheme(.light) .previewDisplayName("DiscoveryView Light") From 786fa30b170a496783c40e3f57a23de163408106 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 6 Aug 2025 16:13:09 +0300 Subject: [PATCH 15/24] fix: removed extra comas --- .../Outline/CourseVertical/CourseVerticalView.swift | 2 +- .../Presentation/DatesAndCalendar/CoursesToSyncView.swift | 2 +- .../Presentation/DatesAndCalendar/DatesAndCalendarView.swift | 2 +- .../Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index 364e9c00a..278215ea3 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -181,7 +181,7 @@ struct CourseVerticalView_Previews: PreviewProvider { sequentialIndex: 0, router: CourseRouterMock(), analytics: CourseAnalyticsMock(), - connectivity: Connectivity(config: ConfigMock()), + connectivity: Connectivity(config: ConfigMock()) ) return Group { diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift index e0791ac31..f1aae91ef 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift @@ -158,7 +158,7 @@ struct CoursesToSyncView_Previews: PreviewProvider { profileStorage: ProfileStorageMock(), persistence: ProfilePersistenceMock(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity(config: ConfigMock()), + connectivity: Connectivity(config: ConfigMock()) ) return CoursesToSyncView(viewModel: vm) .previewDisplayName("Courses to Sync") diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift index 3993b8580..6539ea081 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift @@ -188,7 +188,7 @@ struct DatesAndCalendarView_Previews: PreviewProvider { profileStorage: ProfileStorageMock(), persistence: ProfilePersistenceMock(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity(config: ConfigMock()), + connectivity: Connectivity(config: ConfigMock()) ) DatesAndCalendarView(viewModel: vm) .loadFonts() diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift index e96a0cc1b..be28cc458 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -271,7 +271,7 @@ struct SyncCalendarOptionsView_Previews: PreviewProvider { profileStorage: ProfileStorageMock(), persistence: ProfilePersistenceMock(), calendarManager: CalendarManagerMock(), - connectivity: Connectivity(config: ConfigMock()), + connectivity: Connectivity(config: ConfigMock()) ) SyncCalendarOptionsView(viewModel: vm) .loadFonts() From 0fce30be34b2c813a325b297bae3136ed9bed0f3 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Tue, 16 Sep 2025 11:55:36 +0300 Subject: [PATCH 16/24] fix: unit tests --- .../Course/Presentation/Container/CourseContainerView.swift | 6 +++--- .../Presentation/Container/CourseContainerViewModel.swift | 2 +- Course/Course/Presentation/Content/CourseContentView.swift | 2 +- .../Presentation/Content/Subviews/AllContentView.swift | 2 +- .../Presentation/Progress/CourseProgressScreenView.swift | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 73ea05091..8c8a2828b 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -382,7 +382,7 @@ public struct CourseContainerView: View { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), config: ConfigMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), manager: DownloadManagerMock(), storage: CourseStorageMock(), isActive: true, @@ -398,7 +398,7 @@ public struct CourseContainerView: View { interactor: CourseInteractor.mock, router: CourseRouterMock(), cssInjector: CSSInjectorMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), config: ConfigMock(), courseID: "1", courseName: "a", @@ -409,7 +409,7 @@ public struct CourseContainerView: View { interactor: CourseInteractor.mock, router: CourseRouterMock(), analytics: CourseAnalyticsMock(), - connectivity: Connectivity() + connectivity: Connectivity(config: ConfigMock()), ), courseID: "", title: "Title of Course", diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 2d1b2d051..7324dba43 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -1612,7 +1612,7 @@ extension CourseContainerViewModel { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), config: ConfigMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), manager: DownloadManagerMock(), storage: CourseStorageMock(), isActive: true, diff --git a/Course/Course/Presentation/Content/CourseContentView.swift b/Course/Course/Presentation/Content/CourseContentView.swift index d23590201..dbcca75c7 100644 --- a/Course/Course/Presentation/Content/CourseContentView.swift +++ b/Course/Course/Presentation/Content/CourseContentView.swift @@ -266,7 +266,7 @@ public struct CourseContentView: View { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), config: ConfigMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), manager: DownloadManagerMock(), storage: CourseStorageMock(), isActive: true, diff --git a/Course/Course/Presentation/Content/Subviews/AllContentView.swift b/Course/Course/Presentation/Content/Subviews/AllContentView.swift index 696586dd3..dc37c86b9 100644 --- a/Course/Course/Presentation/Content/Subviews/AllContentView.swift +++ b/Course/Course/Presentation/Content/Subviews/AllContentView.swift @@ -142,7 +142,7 @@ struct AllContentView: View { router: CourseRouterMock(), analytics: CourseAnalyticsMock(), config: ConfigMock(), - connectivity: Connectivity(), + connectivity: Connectivity(config: ConfigMock()), manager: DownloadManagerMock(), storage: CourseStorageMock(), isActive: true, diff --git a/Course/Course/Presentation/Progress/CourseProgressScreenView.swift b/Course/Course/Presentation/Progress/CourseProgressScreenView.swift index 77341907d..7419a52ef 100644 --- a/Course/Course/Presentation/Progress/CourseProgressScreenView.swift +++ b/Course/Course/Presentation/Progress/CourseProgressScreenView.swift @@ -227,7 +227,7 @@ struct CourseProgressScreenView: View { interactor: CourseInteractor.mock, router: CourseRouterMock(), analytics: CourseAnalyticsMock(), - connectivity: Connectivity() + connectivity: Connectivity(config: ConfigMock()) ) CourseProgressScreenView( @@ -236,7 +236,7 @@ struct CourseProgressScreenView: View { collapsed: .constant(false), viewHeight: .constant(0), viewModel: vm, - connectivity: Connectivity() + connectivity: Connectivity(config: ConfigMock()) ) .loadFonts() } From ad3f2608d28add66453fa343d97c25284f1bcc8f Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 29 Oct 2025 17:15:00 +0200 Subject: [PATCH 17/24] fix: removed state object warning --- .../Progress/CourseProgressScreenView.swift | 6 +++- default_config/dev/config.yaml | 35 +++---------------- 2 files changed, 9 insertions(+), 32 deletions(-) diff --git a/Course/Course/Presentation/Progress/CourseProgressScreenView.swift b/Course/Course/Presentation/Progress/CourseProgressScreenView.swift index 9a11ac964..3ee35119e 100644 --- a/Course/Course/Presentation/Progress/CourseProgressScreenView.swift +++ b/Course/Course/Presentation/Progress/CourseProgressScreenView.swift @@ -19,6 +19,7 @@ struct CourseProgressScreenView: View { @StateObject private var viewModel: CourseProgressViewModel + private let initialCourseStructure: CourseStructure? private let connectivity: ConnectivityProtocol @@ -37,7 +38,7 @@ struct CourseProgressScreenView: View { self._viewHeight = viewHeight self._viewModel = StateObject(wrappedValue: { viewModel }()) self.connectivity = connectivity - self.viewModel.courseStructure = courseStructure + self.initialCourseStructure = courseStructure } public var body: some View { @@ -111,6 +112,9 @@ struct CourseProgressScreenView: View { .ignoresSafeArea() ) .onFirstAppear { + if viewModel.courseStructure == nil { + viewModel.courseStructure = initialCourseStructure + } Task { await viewModel.getCourseProgress(courseID: courseID) } diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 9b2c7d64a..0634e3d75 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -1,33 +1,6 @@ -API_HOST_URL: 'https://axim-ccpv-dev.raccoongang.net' +API_HOST_URL: 'http://localhost:8000' SSO_URL: 'http://localhost:8000' -SSO_FINISHED_URL: 'http://localhost:8000' -ENVIRONMENT_DISPLAY_NAME: 'axim-ccpv-dev' +SSO_FINISHED_URL: 'http://localhost:8000' +ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' -OAUTH_CLIENT_ID: 'SxDFlH1rb8Xw3nT6lOPwUejLb9vgXzOfkgqx1sY2' - -SSO_BUTTON_TITLE: - ar: "الدخول عبر SSO" - en: "Sign in with SSO" - -DISCOVERY: - TYPE: "native" - -FIREBASE: - ENABLED: true - ANALYTICS_SOURCE: "firebase" - CLOUD_MESSAGING_ENABLED: false - API_KEY: "AIzaSyCKAIXDLM7pnX43P_viTsfgbxrLBOaJwGo" - BUNDLE_ID: "com.raccoongang.NewEdX.stage" - CLIENT_ID: "156114692773-r5pgdcdjqq7sup75fdla4lk3q3kjc6m8.apps.googleusercontent.com" - GCM_SENDER_ID: "156114692773" - GOOGLE_APP_ID: "1:156114692773:ios:8058bca851a8bc7c187b4c" - PROJECT_ID: "openedxmobile-stage" - REVERSED_CLIENT_ID: "com.googleusercontent.apps.156114692773-r5pgdcdjqq7sup75fdla4lk3q3kjc6m8" - STORAGE_BUCKET: "openedxmobile-stage.appspot.com" - -UI_COMPONENTS: - COURSE_UNIT_PROGRESS_ENABLED: false - COURSE_NESTED_LIST_ENABLED: false - LOGIN_REGISTRATION_ENABLED: true - SAML_SSO_LOGIN_ENABLED: false - SAML_SSO_DEFAULT_LOGIN_BUTTON: false +OAUTH_CLIENT_ID: '' From 39ace76dab2b476e0b974823994eb914bab6f334 Mon Sep 17 00:00:00 2001 From: DemianRaccoonGang Date: Wed, 29 Oct 2025 18:06:51 +0200 Subject: [PATCH 18/24] fix: update config --- default_config/dev/config.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 0634e3d75..0d4e370e5 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -4,3 +4,18 @@ SSO_FINISHED_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' + +SSO_BUTTON_TITLE: + ar: "الدخول عبر SSO" + en: "Sign in with SSO" + +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + +UI_COMPONENTS: + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_NESTED_LIST_ENABLED: false + LOGIN_REGISTRATION_ENABLED: true + SAML_SSO_LOGIN_ENABLED: false + SAML_SSO_DEFAULT_LOGIN_BUTTON: false From 0e0a008cb6ee94678bc648cc1d7aa70a0b3320a8 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:55:54 +0300 Subject: [PATCH 19/24] feat: update swiftlint config --- .swiftlint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.swiftlint.yml b/.swiftlint.yml index b6dcc96e7..8fa216d80 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -19,6 +19,7 @@ opt_in_rules: # some rules are only opt-in excluded: # paths to ignore during linting. Takes precedence over `included`. - Carthage - DerivedData + - build - Pods - DerivedData - Core/CoreTests From 31e2c92c42e7b51bffc7328b44f929c22c533091 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:52:46 +0300 Subject: [PATCH 20/24] feat: add multi-tenant LMS Directory behind a feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a single build browse the Open edX platforms published by a site registry, re-theme to the chosen one, and sign in against it. Off by default (LMS_DIRECTORY.ENABLED=false); stock single-tenant flow is untouched when the flag is off. - Core: LMSDirectoryConfig flag + ConfigProtocol.lmsDirectory; Config.baseURL resolves to the selected LMS when enabled; CoreStorage.selectedLMSBaseURL - Authorization: TenantPicker feature — directory search, QR login, per-LMS runtime theming, selection coordinator, remote + mock services - App: RouteController landing branch, AppDelegate flag-gated registration, LMSDirectoryRouter, NSCameraUsageDescription for QR - Profile: 'Report this LMS' flow (flag-gated) posting to the registry - default_config: LMS_DIRECTORY block for dev/stage/prod - Regenerated mocks for the new ConfigProtocol/CoreStorage members --- .../Generated/AppDatesMocks.generated.swift | 15 +- .../Authorization.xcodeproj/project.pbxproj | 76 ++++ .../TenantPicker/LMSDirectoryAnalytics.swift | 15 + .../LMSDirectoryConfigModels.swift | 39 ++ .../TenantPicker/LMSDirectoryFeature.swift | 143 +++++++ .../LMSDirectoryLandingIntroView.swift | 49 +++ .../LMSDirectoryLandingView.swift | 60 +++ .../LMSDirectoryQRInstructionsView.swift | 43 ++ .../LMSDirectoryQRScannerView.swift | 94 +++++ .../TenantPicker/LMSDirectoryService.swift | 128 ++++++ .../TenantPicker/LMSDirectoryView.swift | 229 +++++++++++ .../TenantPicker/LMSDirectoryViewModel.swift | 274 +++++++++++++ .../TenantPicker/LMSHistoryStore.swift | 219 ++++++++++ .../Presentation/TenantPicker/LMSModels.swift | 215 ++++++++++ .../TenantPicker/LMSOverridesStore.swift | 79 ++++ .../LMSSelectionCoordinator.swift | 78 ++++ .../TenantPicker/LMSThemeApplier.swift | 98 +++++ .../RemoteLMSDirectoryService.swift | 134 +++++++ .../TenantPicker/lms_mock_data.json | 192 +++++++++ .../AuthorizationMocks.generated.swift | 15 +- Core/Core.xcodeproj/project.pbxproj | 4 + Core/Core/Configuration/Config/Config.swift | 9 + .../Config/LMSDirectoryConfig.swift | 45 +++ Core/Core/Data/CoreStorage.swift | 6 +- .../Generated/CoreMocks.generated.swift | 15 +- .../Generated/CourseMocks.generated.swift | 15 +- .../Generated/DashboardMocks.generated.swift | 15 +- .../Generated/DiscoveryMocks.generated.swift | 15 +- .../Generated/DiscussionMocks.generated.swift | 15 +- .../Generated/DownloadsMocks.generated.swift | 15 +- OpenEdX.xcodeproj/project.pbxproj | 4 + OpenEdX/AppDelegate.swift | 12 +- OpenEdX/Data/AppStorage.swift | 15 + OpenEdX/Info.plist | 2 + OpenEdX/LMSDirectoryRouter.swift | 33 ++ OpenEdX/RouteController.swift | 12 +- Profile/Profile.xcodeproj/project.pbxproj | 4 + .../Presentation/Profile/ProfileView.swift | 29 +- .../Presentation/Profile/ReportLMSView.swift | 379 ++++++++++++++++++ .../Generated/ProfileMocks.generated.swift | 15 +- default_config/dev/config.yaml | 5 + default_config/prod/config.yaml | 5 + default_config/stage/config.yaml | 5 + 43 files changed, 2845 insertions(+), 24 deletions(-) create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryConfigModels.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRScannerView.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSHistoryStore.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/RemoteLMSDirectoryService.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/lms_mock_data.json create mode 100644 Core/Core/Configuration/Config/LMSDirectoryConfig.swift create mode 100644 OpenEdX/LMSDirectoryRouter.swift create mode 100644 Profile/Profile/Presentation/Profile/ReportLMSView.swift diff --git a/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift b/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift index 1042e2911..21e90106e 100644 --- a/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift +++ b/AppDates/AppDatesTests/Generated/AppDatesMocks.generated.swift @@ -17,7 +17,7 @@ import ZipArchive public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { public init() { } - public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { + public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, lmsDirectory: LMSDirectoryConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { self.baseURL = baseURL self.baseSSOURL = baseSSOURL self.ssoFinishedURL = ssoFinishedURL @@ -37,6 +37,7 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { self._features = features self._theme = theme self._uiComponents = uiComponents + self._lmsDirectory = lmsDirectory self._discovery = discovery self._dashboard = dashboard self._braze = braze @@ -138,6 +139,12 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { } + private var _lmsDirectory: LMSDirectoryConfig! + public var lmsDirectory: LMSDirectoryConfig { + get { return _lmsDirectory } + set { _lmsDirectory = newValue } + } + private var _uiComponents: UIComponentsConfig! public var uiComponents: UIComponentsConfig { get { return _uiComponents } @@ -257,7 +264,7 @@ public final class CoreAnalyticsMock: CoreAnalytics { public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public init() { } - public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false) { + public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false, selectedLMSBaseURL: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.pushToken = pushToken @@ -273,6 +280,7 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { self.lastUsedSocialAuth = lastUsedSocialAuth self.latestAvailableAppVersion = latestAvailableAppVersion self.updateAppRequired = updateAppRequired + self.selectedLMSBaseURL = selectedLMSBaseURL } @@ -321,6 +329,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public private(set) var updateAppRequiredSetCallCount = 0 public var updateAppRequired: Bool = false { didSet { updateAppRequiredSetCallCount += 1 } } + public private(set) var selectedLMSBaseURLSetCallCount = 0 + public var selectedLMSBaseURL: String? = nil { didSet { selectedLMSBaseURLSetCallCount += 1 } } + private let clearState = MockoloMutex(MockoloHandlerState ()>()) public var clearCallCount: Int { return clearState.withLock(\.callCount) diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index cc7f6c2e9..438144183 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 02A2ACDB2A4B016100FBBBBB /* AuthorizationAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A2ACDA2A4B016100FBBBBB /* AuthorizationAnalytics.swift */; }; 02E0618429DC2373006E9024 /* ResetPasswordViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E0618329DC2373006E9024 /* ResetPasswordViewModelTests.swift */; }; 02F3BFE5292533720051930C /* AuthorizationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE4292533720051930C /* AuthorizationRouter.swift */; }; + 06FC79EABCA9B12D7E8669FA /* LMSModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818570B2BF7697374ACD9ED /* LMSModels.swift */; }; 071009C728D1DA4F00344290 /* SignInViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 071009C628D1DA4F00344290 /* SignInViewModel.swift */; }; 07169458296D913400E3DED6 /* Authorization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0770DE3B28D0A319006D8A5D /* Authorization.framework */; platformFilter = ios; }; 07169464296D96DD00E3DED6 /* SignInViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07169463296D96DD00E3DED6 /* SignInViewModelTests.swift */; }; @@ -24,12 +25,22 @@ 0770DE6828D0BF03006D8A5D /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE6728D0BF03006D8A5D /* swiftgen.yml */; }; 0770DE6B28D0C035006D8A5D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE6D28D0C035006D8A5D /* Localizable.strings */; }; 0770DE7128D0C0E7006D8A5D /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE7028D0C0E7006D8A5D /* Strings.swift */; }; + 0FE969FB22F82F2D38C35356 /* LMSOverridesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6476DC515B100AE6F9D74AED /* LMSOverridesStore.swift */; }; + 39BADC147E3BA2D2401AEB91 /* lms_mock_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 175E5DB220AA2B38AC403B0B /* lms_mock_data.json */; }; + 46E446F8EECA2F03A8ACBB22 /* LMSDirectoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E45B6D76F9942E50F52B04B1 /* LMSDirectoryViewModel.swift */; }; + 4B00D19EC0BDB0F0B4CF4B84 /* LMSDirectoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13A3D56EFC72C383B6B7809 /* LMSDirectoryService.swift */; }; + 4F00E022EA4706DE1EBA0365 /* LMSDirectoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6268F99F8CE485FB7B908D8 /* LMSDirectoryView.swift */; }; 5FB79D2802949372CDAF08D6 /* Pods_App_Authorization_AuthorizationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4FAE9B7FD61FF88C9C4FE1E8 /* Pods_App_Authorization_AuthorizationTests.framework */; }; + 675A3266DB7422CBE5D8C383 /* LMSDirectoryQRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E65F49C6D33DA13103C3592 /* LMSDirectoryQRScannerView.swift */; }; + 6D7A416158E2FDD2F33B92AE /* LMSHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E53AD905F2FFC3F8EBB47210 /* LMSHistoryStore.swift */; }; + 95F57194827709F98991ECF9 /* LMSThemeApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0922F56B70DDA9F2910C6C /* LMSThemeApplier.swift */; }; + 999B1445AA69C9D434DA39CF /* RemoteLMSDirectoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52219A516317365C162B9670 /* RemoteLMSDirectoryService.swift */; }; 99C1654B2C0C4F0600DC384D /* ContainerWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654A2C0C4F0600DC384D /* ContainerWebView.swift */; }; 99C1654D2C0C4F2F00DC384D /* SSOHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654C2C0C4F2F00DC384D /* SSOHelper.swift */; }; 99C1654F2C0C4F5900DC384D /* SSOWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654E2C0C4F5900DC384D /* SSOWebView.swift */; }; 99C165512C0C4F7B00DC384D /* SSOWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C165502C0C4F7B00DC384D /* SSOWebViewModel.swift */; }; A5B468112F29C845002A4ECA /* AuthorizationMocks.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B4680F2F29C845002A4ECA /* AuthorizationMocks.generated.swift */; }; + B9DC07B706D5509A68E70E00 /* LMSDirectoryLandingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B39DF829F04AEA8C86DA599F /* LMSDirectoryLandingView.swift */; }; BA8B3A322AD5487300D25EF5 /* SocialAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8B3A312AD5487300D25EF5 /* SocialAuthView.swift */; }; BADB3F552AD6DFC3004D5CFA /* SocialAuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB3F542AD6DFC3004D5CFA /* SocialAuthViewModel.swift */; }; CE7CAF2D2CC155BE00E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF2C2CC155BE00E0AC9D /* OEXFoundation */; }; @@ -42,9 +53,15 @@ CEB25A052CC13A36007FC792 /* SocialAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB25A002CC13A36007FC792 /* SocialAuthResponse.swift */; }; CEB25A062CC13A36007FC792 /* MicrosoftAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB259FF2CC13A36007FC792 /* MicrosoftAuthProvider.swift */; }; CEB25A072CC13A36007FC792 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB259FA2CC13A36007FC792 /* SocialAuthError.swift */; }; + D3EF716372053A159F03DDF1 /* LMSDirectoryLandingIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90232EACBC849403FA59235A /* LMSDirectoryLandingIntroView.swift */; }; DE843D6BB1B9DDA398494890 /* Pods_App_Authorization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47BCFB7C19382EECF15131B6 /* Pods_App_Authorization.framework */; }; E03261642AE64676002CA7EB /* StartupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03261632AE64676002CA7EB /* StartupViewModel.swift */; }; E03261662AE64AF4002CA7EB /* StartupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03261652AE64AF4002CA7EB /* StartupView.swift */; }; + E1FBF62E1395791882A6A2E2 /* LMSDirectoryConfigModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E4636E092C17473AAC3063 /* LMSDirectoryConfigModels.swift */; }; + E778A8126843DE891196033C /* LMSDirectoryFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54635D2863A11AFCC9A5235 /* LMSDirectoryFeature.swift */; }; + E834B83B7906F8A85301BAEA /* LMSDirectoryAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643ED7945A47425D9B6E18D3 /* LMSDirectoryAnalytics.swift */; }; + F373ABD103D47FC0CC803A5A /* LMSDirectoryQRInstructionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BFB9D3D0BDE2FE146832C0 /* LMSDirectoryQRInstructionsView.swift */; }; + F52BE36894F6BC06B9EF5C06 /* LMSSelectionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD15916DB50A754388E1677F /* LMSSelectionCoordinator.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -90,16 +107,25 @@ 0770DE6C28D0C035006D8A5D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 0770DE7028D0C0E7006D8A5D /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 0E586C84FB9FFDD8AAE29BB3 /* Pods-App-Authorization-AuthorizationTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.debug.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.debug.xcconfig"; sourceTree = ""; }; + 175E5DB220AA2B38AC403B0B /* lms_mock_data.json */ = {isa = PBXFileReference; includeInIndex = 1; name = lms_mock_data.json; path = Authorization/Presentation/TenantPicker/lms_mock_data.json; sourceTree = ""; }; 1CB6628EDEAAC2431CD50D9A /* Pods-App-Authorization-AuthorizationTests.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.debugprod.xcconfig"; sourceTree = ""; }; 1CD50AA5CB635FD7200C4DF9 /* Pods-App-Authorization.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.debugstage.xcconfig"; sourceTree = ""; }; + 2818570B2BF7697374ACD9ED /* LMSModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSModels.swift; path = Authorization/Presentation/TenantPicker/LMSModels.swift; sourceTree = ""; }; 2F1206D6806C156203F01524 /* Pods-App-Authorization-AuthorizationTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.debugstage.xcconfig"; sourceTree = ""; }; 37CBD3ECE7D9B20E0BC61344 /* Pods-App-Authorization-AuthorizationTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.release.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.release.xcconfig"; sourceTree = ""; }; 3E0D103D8210828583660AF6 /* Pods-App-Authorization.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.debug.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.debug.xcconfig"; sourceTree = ""; }; + 3E65F49C6D33DA13103C3592 /* LMSDirectoryQRScannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryQRScannerView.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryQRScannerView.swift; sourceTree = ""; }; 47BCFB7C19382EECF15131B6 /* Pods_App_Authorization.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Authorization.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 47E4636E092C17473AAC3063 /* LMSDirectoryConfigModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryConfigModels.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryConfigModels.swift; sourceTree = ""; }; 49A74E7AC5109DFA06BDAF3A /* Pods-App-Authorization-AuthorizationTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releasedev.xcconfig"; sourceTree = ""; }; 4FAE9B7FD61FF88C9C4FE1E8 /* Pods_App_Authorization_AuthorizationTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Authorization_AuthorizationTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 52219A516317365C162B9670 /* RemoteLMSDirectoryService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteLMSDirectoryService.swift; path = Authorization/Presentation/TenantPicker/RemoteLMSDirectoryService.swift; sourceTree = ""; }; + 643ED7945A47425D9B6E18D3 /* LMSDirectoryAnalytics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryAnalytics.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift; sourceTree = ""; }; + 6476DC515B100AE6F9D74AED /* LMSOverridesStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSOverridesStore.swift; path = Authorization/Presentation/TenantPicker/LMSOverridesStore.swift; sourceTree = ""; }; 68795EBDC3000C1B12F9432C /* Pods-App-Authorization.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.debugprod.xcconfig"; sourceTree = ""; }; + 74BFB9D3D0BDE2FE146832C0 /* LMSDirectoryQRInstructionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryQRInstructionsView.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift; sourceTree = ""; }; 7A84BB166492D4E46FBCF01C /* Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig"; sourceTree = ""; }; + 90232EACBC849403FA59235A /* LMSDirectoryLandingIntroView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryLandingIntroView.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift; sourceTree = ""; }; 90DFBB75EF40580E180D71C8 /* Pods-App-Authorization.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.debugdev.xcconfig"; sourceTree = ""; }; 96C85172770225EB81A6D2DA /* Pods-App-Authorization.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releasedev.xcconfig"; sourceTree = ""; }; 99C1654A2C0C4F0600DC384D /* ContainerWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerWebView.swift; sourceTree = ""; }; @@ -109,8 +135,12 @@ 9BF6A1004A955E24527FCF0F /* Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; sourceTree = ""; }; A5B4680F2F29C845002A4ECA /* AuthorizationMocks.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationMocks.generated.swift; sourceTree = ""; }; A99D45203C981893C104053A /* Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; sourceTree = ""; }; + B13A3D56EFC72C383B6B7809 /* LMSDirectoryService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryService.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryService.swift; sourceTree = ""; }; + B39DF829F04AEA8C86DA599F /* LMSDirectoryLandingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryLandingView.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift; sourceTree = ""; }; BA8B3A312AD5487300D25EF5 /* SocialAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthView.swift; sourceTree = ""; }; BADB3F542AD6DFC3004D5CFA /* SocialAuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthViewModel.swift; sourceTree = ""; }; + BC0922F56B70DDA9F2910C6C /* LMSThemeApplier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSThemeApplier.swift; path = Authorization/Presentation/TenantPicker/LMSThemeApplier.swift; sourceTree = ""; }; + C54635D2863A11AFCC9A5235 /* LMSDirectoryFeature.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryFeature.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift; sourceTree = ""; }; CEB259FA2CC13A36007FC792 /* SocialAuthError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; CEB259FC2CC13A36007FC792 /* AppleAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAuthProvider.swift; sourceTree = ""; }; CEB259FD2CC13A36007FC792 /* GoogleAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuthProvider.swift; sourceTree = ""; }; @@ -119,9 +149,13 @@ CEB25A002CC13A36007FC792 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = ""; }; E03261632AE64676002CA7EB /* StartupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupViewModel.swift; sourceTree = ""; }; E03261652AE64AF4002CA7EB /* StartupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupView.swift; sourceTree = ""; }; + E45B6D76F9942E50F52B04B1 /* LMSDirectoryViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryViewModel.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift; sourceTree = ""; }; + E53AD905F2FFC3F8EBB47210 /* LMSHistoryStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSHistoryStore.swift; path = Authorization/Presentation/TenantPicker/LMSHistoryStore.swift; sourceTree = ""; }; + E6268F99F8CE485FB7B908D8 /* LMSDirectoryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryView.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryView.swift; sourceTree = ""; }; E78971D8E6ED2116BBF9FD66 /* Pods-App-Authorization.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.release.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.release.xcconfig"; sourceTree = ""; }; F52826C68AEA1CF4769389EA /* Pods-App-Authorization.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releasestage.xcconfig"; sourceTree = ""; }; F5802BBA113276950ABCD9B3 /* Pods-App-Authorization.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releaseprod.xcconfig"; sourceTree = ""; }; + FD15916DB50A754388E1677F /* LMSSelectionCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSSelectionCoordinator.swift; path = Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -245,6 +279,7 @@ 0770DE3C28D0A319006D8A5D /* Products */, 0770DE4528D0A3DA006D8A5D /* Frameworks */, 83B4EF244208C770505E10CB /* Pods */, + 1AF6FF0E9FF856AB8F1E02CC /* TenantPicker */, ); sourceTree = ""; }; @@ -286,6 +321,30 @@ path = SwiftGen; sourceTree = ""; }; + 1AF6FF0E9FF856AB8F1E02CC /* TenantPicker */ = { + isa = PBXGroup; + children = ( + C54635D2863A11AFCC9A5235 /* LMSDirectoryFeature.swift */, + 90232EACBC849403FA59235A /* LMSDirectoryLandingIntroView.swift */, + B39DF829F04AEA8C86DA599F /* LMSDirectoryLandingView.swift */, + 74BFB9D3D0BDE2FE146832C0 /* LMSDirectoryQRInstructionsView.swift */, + 3E65F49C6D33DA13103C3592 /* LMSDirectoryQRScannerView.swift */, + E6268F99F8CE485FB7B908D8 /* LMSDirectoryView.swift */, + E45B6D76F9942E50F52B04B1 /* LMSDirectoryViewModel.swift */, + FD15916DB50A754388E1677F /* LMSSelectionCoordinator.swift */, + 643ED7945A47425D9B6E18D3 /* LMSDirectoryAnalytics.swift */, + 47E4636E092C17473AAC3063 /* LMSDirectoryConfigModels.swift */, + B13A3D56EFC72C383B6B7809 /* LMSDirectoryService.swift */, + E53AD905F2FFC3F8EBB47210 /* LMSHistoryStore.swift */, + 2818570B2BF7697374ACD9ED /* LMSModels.swift */, + 6476DC515B100AE6F9D74AED /* LMSOverridesStore.swift */, + 52219A516317365C162B9670 /* RemoteLMSDirectoryService.swift */, + BC0922F56B70DDA9F2910C6C /* LMSThemeApplier.swift */, + 175E5DB220AA2B38AC403B0B /* lms_mock_data.json */, + ); + name = TenantPicker; + sourceTree = ""; + }; 83B4EF244208C770505E10CB /* Pods */ = { isa = PBXGroup; children = ( @@ -479,6 +538,7 @@ files = ( 0770DE6828D0BF03006D8A5D /* swiftgen.yml in Resources */, 0770DE6B28D0C035006D8A5D /* Localizable.strings in Resources */, + 39BADC147E3BA2D2401AEB91 /* lms_mock_data.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -589,6 +649,22 @@ CEB25A052CC13A36007FC792 /* SocialAuthResponse.swift in Sources */, CEB25A062CC13A36007FC792 /* MicrosoftAuthProvider.swift in Sources */, CEB25A072CC13A36007FC792 /* SocialAuthError.swift in Sources */, + E778A8126843DE891196033C /* LMSDirectoryFeature.swift in Sources */, + D3EF716372053A159F03DDF1 /* LMSDirectoryLandingIntroView.swift in Sources */, + B9DC07B706D5509A68E70E00 /* LMSDirectoryLandingView.swift in Sources */, + F373ABD103D47FC0CC803A5A /* LMSDirectoryQRInstructionsView.swift in Sources */, + 675A3266DB7422CBE5D8C383 /* LMSDirectoryQRScannerView.swift in Sources */, + 4F00E022EA4706DE1EBA0365 /* LMSDirectoryView.swift in Sources */, + 46E446F8EECA2F03A8ACBB22 /* LMSDirectoryViewModel.swift in Sources */, + F52BE36894F6BC06B9EF5C06 /* LMSSelectionCoordinator.swift in Sources */, + E834B83B7906F8A85301BAEA /* LMSDirectoryAnalytics.swift in Sources */, + E1FBF62E1395791882A6A2E2 /* LMSDirectoryConfigModels.swift in Sources */, + 4B00D19EC0BDB0F0B4CF4B84 /* LMSDirectoryService.swift in Sources */, + 6D7A416158E2FDD2F33B92AE /* LMSHistoryStore.swift in Sources */, + 06FC79EABCA9B12D7E8669FA /* LMSModels.swift in Sources */, + 0FE969FB22F82F2D38C35356 /* LMSOverridesStore.swift in Sources */, + 999B1445AA69C9D434DA39CF /* RemoteLMSDirectoryService.swift in Sources */, + 95F57194827709F98991ECF9 /* LMSThemeApplier.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift new file mode 100644 index 000000000..88cf247ac --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift @@ -0,0 +1,15 @@ +import Foundation + +protocol LMSDirectoryAnalytics: Sendable { + func searchStarted(query: String) + func searchResultsShown(count: Int) + func selectionMade(id: String, fromHistory: Bool) + func historyCleared() +} + +struct LMSDirectoryAnalyticsNoop: LMSDirectoryAnalytics { + func searchStarted(query: String) {} + func searchResultsShown(count: Int) {} + func selectionMade(id: String, fromHistory: Bool) {} + func historyCleared() {} +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryConfigModels.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryConfigModels.swift new file mode 100644 index 000000000..6ac51ec28 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryConfigModels.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Public configuration served by the registry (`GET /api/v1/config`). +/// Drives whether the app shows a search box or a fixed, curated list. +struct LMSRegistryConfig: Sendable, Equatable { + let directoryMode: String + let providerName: String + let providerTagline: String + + var isCurated: Bool { directoryMode.lowercased() == "curated" } + + static let searchDefault = LMSRegistryConfig( + directoryMode: "search", + providerName: "", + providerTagline: "" + ) +} + +// MARK: - Wire DTOs + +struct LMSConfigDTO: Codable { + let directoryMode: String + let providerName: String + let providerTagline: String + + enum CodingKeys: String, CodingKey { + case directoryMode = "directory_mode" + case providerName = "provider_name" + case providerTagline = "provider_tagline" + } + + var domainModel: LMSRegistryConfig { + LMSRegistryConfig( + directoryMode: directoryMode, + providerName: providerName, + providerTagline: providerTagline + ) + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift new file mode 100644 index 000000000..54a5f8969 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift @@ -0,0 +1,143 @@ +import Core +import Foundation +import SwiftUI +import UIKit +import Swinject + +public struct LMSDirectorySelectionInfo: Equatable, Sendable { + public let title: String + public let logoURL: URL? +} + +public enum LMSDirectoryFeature { + + private nonisolated(unsafe) static var isRegistered = false + private nonisolated(unsafe) static var isEnabled = false + private nonisolated(unsafe) static var directoryURL: URL? + private nonisolated(unsafe) static var logoutObserver: NSObjectProtocol? + + private static let landingTitle = NSLocalizedString( + "Find your LMS", + comment: "Title for universal app landing screen" + ) + + public static func register(directoryBaseURL: String? = nil) { + guard !isRegistered else { return } + isRegistered = true + isEnabled = true + if let urlString = directoryBaseURL, let url = URL(string: urlString) { + directoryURL = url + // Bridge the registry URL to shared storage so other modules (e.g. the + // Profile tab's "Report this LMS") can post complaints without a direct + // dependency on this feature. Mirrors how the selected LMS base URL is shared. + UserDefaults.standard.set(url.absoluteString, forKey: "lmsRegistryURL") + } + registerDependencies() + applyPersistedSelectionIfNeeded() + observeLogout() + } + + public static func shouldPresentLanding(storage: CoreStorage?) -> Bool { + guard isEnabled else { return false } + let selectedUrl = storage?.selectedLMSBaseURL + return selectedUrl == nil || selectedUrl?.isEmpty == true + } + + @MainActor + public static func makeLandingController() -> UIViewController { + let viewModel = makeViewModel() + let view = LMSDirectoryLandingView(viewModel: viewModel) + let controller = UIHostingController(rootView: view) + controller.title = landingTitle + controller.navigationItem.largeTitleDisplayMode = .never + return controller + } + + public static func currentSelectionInfo() -> LMSDirectorySelectionInfo? { + guard isEnabled else { return nil } + let overrides = Container.shared.resolve(LMSOverridesStoreProtocol.self) ?? LMSOverridesStore() + guard let detail = overrides.currentSelection() else { return nil } + return LMSDirectorySelectionInfo( + title: detail.title, + logoURL: detail.logoURL + ) + } + + @MainActor + private static func makeViewModel() -> LMSDirectoryViewModel { + let container = Container.shared + // The coordinator is @MainActor; build it here (this factory is @MainActor) + // rather than via a nonisolated Swinject factory. + let coordinator = LMSSelectionCoordinator( + historyStore: container.resolve(LMSHistoryStoreProtocol.self)!, + overridesStore: container.resolve(LMSOverridesStoreProtocol.self)!, + analytics: container.resolve(LMSDirectoryAnalytics.self)!, + router: container.resolve(LMSSelectionRouting.self), + coreStorage: container.resolve(CoreStorage.self), + container: container + ) + return LMSDirectoryViewModel( + service: container.resolve(LMSDirectoryService.self)!, + historyStore: container.resolve(LMSHistoryStoreProtocol.self)!, + coordinator: coordinator, + overridesStore: container.resolve(LMSOverridesStoreProtocol.self)!, + analytics: container.resolve(LMSDirectoryAnalytics.self)!, + connectivity: container.resolve(ConnectivityProtocol.self)! + ) + } + + private static func registerDependencies() { + let container = Container.shared + + container.register(LMSHistoryStoreProtocol.self) { _ in + LMSHistoryStore() + }.inObjectScope(.container) + + container.register(LMSOverridesStoreProtocol.self) { _ in + LMSOverridesStore() + }.inObjectScope(.container) + + container.register(LMSDirectoryAnalytics.self) { _ in + LMSDirectoryAnalyticsNoop() + }.inObjectScope(.container) + + container.register(LMSDirectoryService.self) { resolver in + let connectivity = resolver.resolve(ConnectivityProtocol.self)! + if let baseURL = directoryURL { + return RemoteLMSDirectoryService( + baseURL: baseURL, + connectivity: connectivity + ) + } + return MockLMSDirectoryService(connectivity: connectivity) + }.inObjectScope(.container) + } + + private static func applyPersistedSelectionIfNeeded() { + let overrides = Container.shared.resolve(LMSOverridesStoreProtocol.self) ?? LMSOverridesStore() + guard let selection = overrides.currentSelection() else { return } + + LMSThemeApplier.applyAccentColor(selection.accentColor, darkColor: selection.accentColorDark) + // Config classes (UIComponentsConfig, DashboardConfig, FeaturesConfig) + // read UserDefaults overrides automatically — no manual override needed + } + + private static func observeLogout() { + logoutObserver = NotificationCenter.default.addObserver( + forName: .userLoggedOut, + object: nil, + queue: nil + ) { _ in + resetOverrides() + } + } + + private static func resetOverrides() { + let overrides = Container.shared.resolve(LMSOverridesStoreProtocol.self) ?? LMSOverridesStore() + let history = Container.shared.resolve(LMSHistoryStoreProtocol.self) ?? LMSHistoryStore() + let storage = Container.shared.resolve(CoreStorage.self) + try? overrides.clear(storage: storage) + try? history.unpinAll() + LMSThemeApplier.applyAccentColor(nil) + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift new file mode 100644 index 000000000..7710dc11a --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import Theme + +struct LMSDirectoryLandingIntroView: View { + var onFindTapped: () -> Void + var onQRTapped: () -> Void + + var body: some View { + VStack(spacing: 32) { + Spacer(minLength: 120) + VStack(spacing: 12) { + ThemeAssets.appLogo.swiftUIImage + .resizable() + .scaledToFit() + .frame(width: 140, height: 140) +// .foregroundColor(Theme.Colors.accentColor) + .accessibilityHidden(true) + Text("Welcome to Open X Project") + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) + Text("Connect to any LMS in our library to explore courses or continue learning.") + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textSecondary) + .multilineTextAlignment(.center) + } + Spacer() + VStack(spacing: 16) { + Button(action: onFindTapped) { + Text("Find my LMS") + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Theme.Colors.accentColor) + .clipShape(Theme.Shapes.buttonShape) + } + Button(action: onQRTapped) { + HStack(spacing: 8) { + Image(systemName: "qrcode.viewfinder") + Text("QR Login") + } + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.infoColor) + } + } + } + .frame(maxWidth: .infinity) + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift new file mode 100644 index 000000000..61ab20ccc --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import Theme + +struct LMSDirectoryLandingView: View { + @StateObject private var viewModel: LMSDirectoryViewModel + @State private var showingSearch = false + @State private var showingQRInfo = false + @State private var showingQRScanner = false + @State private var qrError: String? + + init(viewModel: LMSDirectoryViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if showingSearch || viewModel.isCurated { + LMSDirectoryView( + viewModel: viewModel, + onScanTapped: { showingQRInfo = true } + ) + } else { + LMSDirectoryLandingIntroView( + onFindTapped: { showingSearch = true }, + onQRTapped: { showingQRInfo = true } + ) + } + } + .padding(24) + } + .background(Theme.Colors.background.ignoresSafeArea()) + .sheet(isPresented: $showingQRInfo) { + LMSDirectoryQRInstructionsView { + showingQRInfo = false + showingQRScanner = true + } + } + .sheet(isPresented: $showingQRScanner) { + LMSDirectoryQRScannerView { + showingQRScanner = false + } onCodeScanned: { code in + showingQRScanner = false + if viewModel.handleScannedURL(code) { + showingSearch = true + } else { + qrError = "We couldn't read the QR code. Try again." + } + } + } + .alert("QR Error", isPresented: Binding( + get: { qrError != nil }, + set: { if !$0 { qrError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(qrError ?? "") + } + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift new file mode 100644 index 000000000..96531a174 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift @@ -0,0 +1,43 @@ +import SwiftUI +import Theme + +struct LMSDirectoryQRInstructionsView: View { + var onScan: () -> Void + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + Text("QR login") + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) + Text( + "1. On your desktop, visit https://your-lms.com/qr (replace with your LMS URL).\n" + + "2. The page will display a QR code.\n" + + "3. Tap “Scan QR” below and point your camera at the code to continue." + ) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textSecondary) + Spacer() + Button(action: onScan) { + Text("Scan QR") + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Theme.Colors.accentColor) + .clipShape(Theme.Shapes.buttonShape) + } + } + .padding(24) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + } + } + } + } + + @Environment(\.dismiss) private var dismiss +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRScannerView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRScannerView.swift new file mode 100644 index 000000000..7600befc1 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRScannerView.swift @@ -0,0 +1,94 @@ +import AVFoundation +import SwiftUI + +struct LMSDirectoryQRScannerView: UIViewControllerRepresentable { + var onCancel: () -> Void + var onCodeScanned: (String) -> Void + + func makeUIViewController(context: Context) -> QRScannerViewController { + let controller = QRScannerViewController() + controller.onCancel = onCancel + controller.onCodeScanned = onCodeScanned + return controller + } + + func updateUIViewController(_ uiViewController: QRScannerViewController, context: Context) {} + + final class QRScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onCancel: (() -> Void)? + var onCodeScanned: ((String) -> Void)? + + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + setupCamera() + setupOverlay() + } + + private func setupCamera() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) + else { + return + } + + session.addInput(input) + + let output = AVCaptureMetadataOutput() + if session.canAddOutput(output) { + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + output.metadataObjectTypes = [.qr, .aztec, .dataMatrix] + } + + let preview = AVCaptureVideoPreviewLayer(session: session) + preview.videoGravity = .resizeAspectFill + preview.frame = view.bounds + view.layer.addSublayer(preview) + previewLayer = preview + + session.startRunning() + } + + private func setupOverlay() { + let closeButton = UIButton(type: .system) + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButton.setTitle("Close", for: .normal) + closeButton.setTitleColor(.white, for: .normal) + closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) + view.addSubview(closeButton) + + NSLayoutConstraint.activate([ + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + closeButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16) + ]) + } + + @objc private func closeTapped() { + session.stopRunning() + onCancel?() + } + + nonisolated func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let value = metadata.stringValue + else { + return + } + // The delegate queue is DispatchQueue.main (set in setupCamera), so we are + // guaranteed to be on the main actor here. + MainActor.assumeIsolated { + session.stopRunning() + onCodeScanned?(value) + } + } + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift new file mode 100644 index 000000000..062810e7c --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift @@ -0,0 +1,128 @@ +import Core +import Foundation + +enum LMSDirectoryError: Error { + case notFound + case offline + case decodingFailed +} + +protocol LMSDirectoryService: Sendable { + func search(query: String) async throws -> [LMSSearchResult] + func fetchDetails(id: String) async throws -> LMSDetail + /// Registry configuration (search vs curated / provider mode). + func fetchConfig() async throws -> LMSRegistryConfig + /// The provider's fixed list, shown directly in curated mode. + func fetchFeatured() async throws -> [LMSSearchResult] +} + +private final class UniversalAppBundleToken {} + +final class MockLMSDirectoryService: LMSDirectoryService { + private let dataSource: [LMSDetail] + private let connectivity: ConnectivityProtocol + private let latency: TimeInterval + + init( + bundle: Bundle = .lmsSelection, + resourceName: String = "lms_mock_data", + connectivity: ConnectivityProtocol, + latency: TimeInterval = 0.3 + ) { + self.connectivity = connectivity + self.latency = latency + self.dataSource = MockLMSDirectoryService.loadData(bundle: bundle, resourceName: resourceName) + } + + func search(query: String) async throws -> [LMSSearchResult] { + try await simulateLatency() + try await ensureOnline() + let normalized = query.lowercased() + return dataSource + .filter { + normalized.isEmpty || + $0.title.lowercased().contains(normalized) || + $0.baseURL.absoluteString.lowercased().contains(normalized) + } + .map { + LMSSearchResult( + id: $0.id, + title: $0.title, + shortDescription: $0.shortDescription, + baseURL: $0.baseURL, + logoURL: $0.logoURL, + accentColorHex: $0.accentColorHex + ) + } + } + + func fetchDetails(id: String) async throws -> LMSDetail { + try await simulateLatency() + try await ensureOnline() + guard let detail = dataSource.first(where: { $0.id == id }) else { + throw LMSDirectoryError.notFound + } + return detail + } + + func fetchConfig() async throws -> LMSRegistryConfig { + .searchDefault + } + + func fetchFeatured() async throws -> [LMSSearchResult] { + try await simulateLatency() + return dataSource.map { + LMSSearchResult( + id: $0.id, + title: $0.title, + shortDescription: $0.shortDescription, + baseURL: $0.baseURL, + logoURL: $0.logoURL, + accentColorHex: $0.accentColorHex + ) + } + } + + private func simulateLatency() async throws { + if latency > 0 { + try await Task.sleep(nanoseconds: UInt64(latency * 1_000_000_000)) + } + } + + private func ensureOnline() async throws { + try await MainActor.run { + guard connectivity.isInternetAvaliable else { + throw LMSDirectoryError.offline + } + } + } + + private static func loadData(bundle: Bundle, resourceName: String) -> [LMSDetail] { + guard + let url = bundle.url(forResource: resourceName, withExtension: "json"), + let data = try? Data(contentsOf: url) + else { + assertionFailure("Missing LMS mock data file.") + return [] + } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let details = try decoder.decode([LMSDetailDTO].self, from: data) + return details.map(\.domainModel) + } catch { + assertionFailure("Failed to decode LMS mock data: \(error)") + return [] + } + } +} + +private extension Bundle { + static var lmsSelection: Bundle { + #if SWIFT_PACKAGE + return .module + #else + return Bundle(for: UniversalAppBundleToken.self) + #endif + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift new file mode 100644 index 000000000..b990b0439 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift @@ -0,0 +1,229 @@ +import Core +import SwiftUI +import Theme +import Kingfisher + +struct LMSDirectoryView: View { + @ObservedObject var viewModel: LMSDirectoryViewModel + var onScanTapped: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if !viewModel.isCurated { + searchField + } + content + } + .padding(.vertical, 12) + .accessibilityIdentifier("lms_directory_container") + } + + private var searchField: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textInputTextColor) + TextField("Enter LMS URL or name", text: $viewModel.searchText) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.webSearch) + .submitLabel(.search) + .frame(maxWidth: .infinity, alignment: .leading) + Button(action: { onScanTapped?() }) { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(Theme.Colors.infoColor) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .accessibilityIdentifier("qr_search_button") + } + .padding(.vertical, 14) + .padding(.horizontal, 16) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + } + } + + @ViewBuilder + private var content: some View { + switch viewModel.state { + case .idle: + placeholder(text: "Start typing to find your platform.") + case .history: + historySection + case .searching: + loadingView + case .results: + resultsSection + case .empty: + placeholder(text: "We haven't found any suitable LMS yet.") + case .offline: + placeholder(text: "No internet connection") + case .error(let message): + placeholder(text: message) + } + } + + private var historySection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("History") + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + Spacer() + Button("Clean history") { + viewModel.clearHistory() + } + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.infoColor) + } + ForEach(viewModel.history) { item in + historyRow(item) + } + } + } + + private var resultsSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Results") + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + ForEach(viewModel.results) { result in + resultRow(result) + } + } + } + + private func resultRow(_ item: LMSSearchResult) -> some View { + Button(action: { viewModel.selectResult(item) }) { + LMSRowContent( + title: item.title, + subtitle: item.shortDescription, + url: item.baseURL, + logoURL: item.logoURL, + accentColorHex: item.accentColorHex + ) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("lms_result_\(item.id)") + } + + private func historyRow(_ item: LMSHistoryItem) -> some View { + let detail = item.decodedDetail() + return Button(action: { viewModel.selectHistoryItem(item) }) { + LMSRowContent( + title: item.title, + subtitle: item.shortDescription, + url: item.baseURL, + logoURL: item.logoURL, + accentColorHex: detail?.accentColorHex + ) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("lms_history_\(item.id)") + } + + private var loadingView: some View { + HStack(spacing: 12) { + ProgressView() + Text("Searching…") + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textSecondary) + } + .padding(.vertical, 8) + } + + private func placeholder(text: String) -> some View { + Text(text) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textSecondary) + .padding(.vertical, 8) + } +} + +private struct LMSRowContent: View { + let title: String + let subtitle: String + let url: URL + let logoURL: URL? + let accentColorHex: String? + + private var badgeColor: Color { + if let hex = accentColorHex, let lmsColor = LMSColor(hex: hex) { + return Color(red: lmsColor.red, green: lmsColor.green, blue: lmsColor.blue) + } + return Theme.Colors.accentColor + } + + private var lmsInitialsView: some View { + let initials = title + .split(separator: " ") + .prefix(2) + .compactMap { $0.first.map(String.init) } + .joined() + .uppercased() + return RoundedRectangle(cornerRadius: 8) + .fill(badgeColor) + .frame(width: 44, height: 44) + .overlay( + Text(initials.isEmpty ? String(title.prefix(1)).uppercased() : initials) + .font(Theme.Fonts.titleSmall) + .foregroundColor(.white) + ) + } + + var body: some View { + HStack(spacing: 12) { + if let logoURL { + KFImage.url(logoURL) + .placeholder { + lmsInitialsView + } + .onFailure { _ in } + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 44, height: 44) + .cornerRadius(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Theme.Colors.background) + ) + } else { + lmsInitialsView + } + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + Text(subtitle) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textSecondary) + Text(url.host ?? url.absoluteString) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textSecondary) + } + Spacer() + Image(systemName: "chevron.right") + .foregroundColor(Theme.Colors.textSecondary) + } + .padding(16) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.background) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke.opacity(0.4)) + ) + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift new file mode 100644 index 000000000..d592a0661 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift @@ -0,0 +1,274 @@ +import Combine +import Core +import Foundation + +@MainActor +final class LMSDirectoryViewModel: ObservableObject { + + enum ViewState: Equatable { + case idle + case history + case searching + case results + case empty + case offline + case error(String) + } + + @Published var searchText: String = "" { + didSet { + scheduleSearch() + } + } + @Published private(set) var results: [LMSSearchResult] = [] + @Published private(set) var history: [LMSHistoryItem] = [] + @Published private(set) var state: ViewState = .history + /// When the registry runs in curated / provider mode the app shows a fixed + /// list of the provider's own instances instead of a search box. + @Published private(set) var isCurated: Bool = false + + private let service: LMSDirectoryService + private let historyStore: LMSHistoryStoreProtocol + private let coordinator: LMSSelectionCoordinating + private let overridesStore: LMSOverridesStoreProtocol + private let analytics: LMSDirectoryAnalytics + private let connectivity: ConnectivityProtocol + private let historyLimit = 10 + + private var debounceTask: Task? + private var historyTask: Task? + private var cancellables: Set = [] + + init( + service: LMSDirectoryService, + historyStore: LMSHistoryStoreProtocol, + coordinator: LMSSelectionCoordinating, + overridesStore: LMSOverridesStoreProtocol, + analytics: LMSDirectoryAnalytics, + connectivity: ConnectivityProtocol + ) { + self.service = service + self.historyStore = historyStore + self.coordinator = coordinator + self.overridesStore = overridesStore + self.analytics = analytics + self.connectivity = connectivity + observeConnectivity() + loadHistory() + applyPersistedTheme() + loadConfig() + } + + deinit { + debounceTask?.cancel() + historyTask?.cancel() + } + + func clearHistory() { + do { + try historyStore.clearHistory() + history = [] + state = searchText.isEmpty ? .idle : state + analytics.historyCleared() + } catch { + state = .error("Unable to clear history.") + } + } + + func selectHistoryItem(_ item: LMSHistoryItem) { + // Always fetch fresh config from server; fall back to cache if offline + Task { + await fetchAndApplyDetails( + id: item.id, + fromHistory: true, + fallback: item.decodedDetail() + ) + } + } + + func selectResult(_ result: LMSSearchResult) { + Task { await fetchAndApplyDetails(id: result.id, fromHistory: false) } + } + + // MARK: - Private + + private func loadConfig() { + Task { [weak self] in + guard let self else { return } + let config = (try? await service.fetchConfig()) ?? .searchDefault + await MainActor.run { self.isCurated = config.isCurated } + if config.isCurated { + await self.loadFeatured() + } + } + } + + private func loadFeatured() async { + await MainActor.run { self.state = .searching } + do { + let items = try await service.fetchFeatured() + await MainActor.run { + self.results = items + self.state = items.isEmpty ? .empty : .results + } + } catch { + await MainActor.run { + self.state = .error("We couldn't load the list of platforms.") + } + } + } + + private func loadHistory() { + historyTask?.cancel() + historyTask = Task { [weak self] in + guard let self else { return } + let entries = historyStore.fetchHistory(limit: historyLimit) + await MainActor.run { + self.history = entries + self.state = self.searchText.isEmpty ? (entries.isEmpty ? .idle : .history) : self.state + } + } + } + + private func observeConnectivity() { + connectivity.internetReachableSubject + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let state, let self else { return } + if case .notReachable = state, !self.searchText.isEmpty { + self.state = .offline + } + } + .store(in: &cancellables) + } + + private func scheduleSearch() { + debounceTask?.cancel() + guard !searchText.isEmpty else { + state = history.isEmpty ? .idle : .history + results = [] + return + } + state = .searching + analytics.searchStarted(query: searchText) + let query = searchText + debounceTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: 500_000_000) + await self?.performSearch(query: query) + } catch { + // Task cancelled + } + } + } + + private func performSearch(query: String) async { + do { + let results = try await service.search(query: query) + await MainActor.run { + self.results = results + if results.isEmpty { + self.state = .empty + } else { + self.state = .results + self.analytics.searchResultsShown(count: results.count) + } + } + } catch LMSDirectoryError.offline { + await MainActor.run { + self.state = .offline + } + } catch { + await MainActor.run { + self.state = .error("We couldn't load the list of platforms.") + } + } + } + + private func fetchAndApplyDetails(id: String, fromHistory: Bool, fallback: LMSDetail? = nil) async { + do { + let detail = try await service.fetchDetails(id: id) + await apply(detail: detail, fromHistory: fromHistory) + } catch LMSDirectoryError.offline { + // Offline — use cached data if available + if let fallback { + await apply(detail: fallback, fromHistory: fromHistory) + } else { + state = .offline + } + } catch { + // Network error — try fallback from cache + if let fallback { + await apply(detail: fallback, fromHistory: fromHistory) + } else { + state = .error("Failed to load LMS settings.") + } + } + } + + private func apply(detail: LMSDetail, fromHistory: Bool) async { + guard let payload = try? JSONEncoder().encode(detail.asDTO()) else { + state = .error("This LMS returned invalid data.") + return + } + await coordinator.applySelection( + detail: detail, + payload: payload, + fromHistory: fromHistory + ) + loadHistory() + } + + private func applyPersistedTheme() { + if let selection = overridesStore.currentSelection() { + LMSThemeApplier.applyAccentColor(selection.accentColor, darkColor: selection.accentColorDark) + } + } + + func handleScannedURL(_ value: String) -> Bool { + guard let normalized = normalizeScannedValue(value) else { + state = .error("Unable to read QR code. Try again.") + return false + } + searchText = normalized + return true + } + + private func normalizeScannedValue(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let url = URL(string: trimmed), let host = url.host { + return host + } + + if let url = URL(string: "https://\(trimmed)"), let host = url.host { + return host + } + + return nil + } +} + +private extension LMSDetail { + func asDTO() -> LMSDetailDTO { + LMSDetailDTO( + id: id, + title: title, + description: description, + api: .init( + hostURL: api.hostURL, + feedbackEmail: api.feedbackEmail, + oauthClientId: api.oauthClientId + ), + featureFlags: featureFlags, + theme: theme, + uiComponents: uiComponents, + dashboard: dashboard, + accentColor: accentColorHex, + shortDescription: shortDescription, + baseURL: baseURL, + logoURL: logoURL + ) + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSHistoryStore.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSHistoryStore.swift new file mode 100644 index 000000000..eb4cb3331 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSHistoryStore.swift @@ -0,0 +1,219 @@ +import CoreData +import Foundation + +protocol LMSHistoryStoreProtocol: Sendable { + func fetchHistory(limit: Int) -> [LMSHistoryItem] + func save(detail: LMSDetail, payload: Data, pinned: Bool) throws + func clearHistory() throws + func deleteAllOverrides() throws + func unpinAll() throws + func pinnedItem() -> LMSHistoryItem? +} + +final class LMSHistoryStore: LMSHistoryStoreProtocol { + private enum Constants { + static let entityName = "LMSHistoryEntry" + static let storeName = "lmsDirectory_lms_history" + static let maxEntries = 10 + } + + private let container: NSPersistentContainer + private let queue = DispatchQueue(label: "com.lmsDirectory.lmsHistoryStore", qos: .userInitiated) + + init() { + let model = Self.makeModel() + container = NSPersistentContainer(name: Constants.storeName, managedObjectModel: model) + if let description = container.persistentStoreDescriptions.first { + let url = Self.storeURL() + description.url = url + } + container.loadPersistentStores { _, error in + if let error { + assertionFailure("Failed to load LMS history store: \(error)") + } + } + container.viewContext.mergePolicy = NSMergePolicy(merge: .mergeByPropertyObjectTrumpMergePolicyType) + container.viewContext.automaticallyMergesChangesFromParent = true + } + + func fetchHistory(limit: Int = Constants.maxEntries) -> [LMSHistoryItem] { + queue.sync { + let context = container.newBackgroundContext() + return context.performAndWait { + let request = NSFetchRequest(entityName: Constants.entityName) + request.fetchLimit = limit + request.sortDescriptors = [ + NSSortDescriptor(key: #keyPath(LMSHistoryEntry.pinned), ascending: false), + NSSortDescriptor(key: #keyPath(LMSHistoryEntry.lastOpenedAt), ascending: false) + ] + do { + return try context.fetch(request).compactMap { $0.toDomain() } + } catch { + return [] + } + } + } + } + + func save(detail: LMSDetail, payload: Data, pinned: Bool) throws { + try queue.sync { + let context = container.newBackgroundContext() + try context.performAndWait { + let request = NSFetchRequest(entityName: Constants.entityName) + request.predicate = NSPredicate(format: "id == %@", detail.id) + let entry = try context.fetch(request).first ?? LMSHistoryEntry(context: context) + entry.id = detail.id + entry.title = detail.title + entry.shortDescription = detail.shortDescription + entry.baseURL = detail.baseURL.absoluteString + entry.logoURL = detail.logoURL?.absoluteString + entry.payload = payload + entry.lastOpenedAt = Date() + entry.pinned = pinned + try context.save() + try trimIfNeeded(context: context) + } + } + } + + func clearHistory() throws { + try queue.sync { + let context = container.newBackgroundContext() + try context.performAndWait { + let request = NSFetchRequest(entityName: Constants.entityName) + let batchDelete = NSBatchDeleteRequest(fetchRequest: request) + try context.execute(batchDelete) + try context.save() + } + } + } + + func deleteAllOverrides() throws { + try queue.sync { + let context = container.newBackgroundContext() + try context.performAndWait { + let request = NSFetchRequest(entityName: Constants.entityName) + request.predicate = NSPredicate(format: "pinned == YES") + let pinnedItems = try context.fetch(request) + pinnedItems.forEach { $0.pinned = false } + try context.save() + } + } + } + + func unpinAll() throws { + try queue.sync { + let context = container.newBackgroundContext() + try context.performAndWait { + let request = NSFetchRequest(entityName: Constants.entityName) + request.predicate = NSPredicate(format: "pinned == YES") + let entries = try context.fetch(request) + entries.forEach { $0.pinned = false } + try context.save() + } + } + } + + func pinnedItem() -> LMSHistoryItem? { + queue.sync { + let context = container.newBackgroundContext() + return context.performAndWait { + let request = NSFetchRequest(entityName: Constants.entityName) + request.predicate = NSPredicate(format: "pinned == YES") + request.fetchLimit = 1 + request.sortDescriptors = [ + NSSortDescriptor(key: #keyPath(LMSHistoryEntry.lastOpenedAt), ascending: false) + ] + return try? context.fetch(request).first?.toDomain() + } + } + } + + // MARK: - Private + + private func trimIfNeeded(context: NSManagedObjectContext) throws { + try context.performAndWait { + let request = NSFetchRequest(entityName: Constants.entityName) + request.sortDescriptors = [NSSortDescriptor(key: #keyPath(LMSHistoryEntry.lastOpenedAt), ascending: false)] + let entries = try context.fetch(request) + guard entries.count > Constants.maxEntries else { return } + entries + .suffix(from: Constants.maxEntries) + .forEach { context.delete($0) } + try context.save() + } + } + + private static func makeModel() -> NSManagedObjectModel { + let model = NSManagedObjectModel() + + let entity = NSEntityDescription() + entity.name = Constants.entityName + entity.managedObjectClassName = NSStringFromClass(LMSHistoryEntry.self) + + entity.properties = [ + attribute(name: "id", type: .stringAttributeType, isOptional: false, indexed: true), + attribute(name: "title", type: .stringAttributeType), + attribute(name: "shortDescription", type: .stringAttributeType), + attribute(name: "baseURL", type: .stringAttributeType), + attribute(name: "logoURL", type: .stringAttributeType), + attribute(name: "lastOpenedAt", type: .dateAttributeType), + attribute(name: "payload", type: .binaryDataAttributeType), + attribute(name: "pinned", type: .booleanAttributeType) + ] + + model.entities = [entity] + return model + } + + private static func attribute( + name: String, + type: NSAttributeType, + isOptional: Bool = true, + indexed: Bool = false + ) -> NSAttributeDescription { + let attribute = NSAttributeDescription() + attribute.name = name + attribute.attributeType = type + attribute.isOptional = isOptional + return attribute + } + + private static func storeURL() -> URL { + let urls = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + let base = urls.first ?? URL(fileURLWithPath: NSTemporaryDirectory()) + let directory = base.appendingPathComponent("LMSDirectory", isDirectory: true) + if !FileManager.default.fileExists(atPath: directory.path) { + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + return directory.appendingPathComponent("\(Constants.storeName).sqlite") + } +} + +@objc(LMSHistoryEntry) +private final class LMSHistoryEntry: NSManagedObject { + @NSManaged var id: String + @NSManaged var title: String + @NSManaged var shortDescription: String + @NSManaged var baseURL: String + @NSManaged var logoURL: String? + @NSManaged var lastOpenedAt: Date + @NSManaged var payload: Data + @NSManaged var pinned: Bool + + func toDomain() -> LMSHistoryItem? { + guard let baseURL = URL(string: baseURL) else { + return nil + } + return LMSHistoryItem( + id: id, + title: title, + shortDescription: shortDescription, + baseURL: baseURL, + logoURL: logoURL.flatMap(URL.init(string:)), + lastOpenedAt: lastOpenedAt, + payloadData: payload, + isPinned: pinned + ) + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift new file mode 100644 index 000000000..d7689283c --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift @@ -0,0 +1,215 @@ +import Foundation + +struct LMSSearchResult: Identifiable, Hashable, Sendable { + let id: String + let title: String + let shortDescription: String + let baseURL: URL + let logoURL: URL? + let accentColorHex: String? +} + +struct LMSDetail: Identifiable, Hashable, Sendable { + struct API: Hashable, Sendable { + let hostURL: URL + let feedbackEmail: String + let oauthClientId: String + } + + struct FeatureFlags: Hashable, Sendable, Codable { + let preLoginDiscovery: Bool + let unknownUnitsMode: String? + + enum CodingKeys: String, CodingKey { + case preLoginDiscovery = "pre_login_discovery" + case unknownUnitsMode = "unknown_units_mode" + } + } + + struct Theme: Hashable, Sendable, Codable { + let accentColorDark: String? + let loginBackgroundURL: URL? + let logoUploadURL: URL? + + enum CodingKeys: String, CodingKey { + case accentColorDark = "accent_color_dark" + case loginBackgroundURL = "login_background_url" + case logoUploadURL = "logo_upload_url" + } + } + + struct UIComponents: Hashable, Sendable, Codable { + let courseUnitProgressEnabled: Bool + let courseDropdownNavigationEnabled: Bool + let preLoginExperienceEnabled: Bool + + enum CodingKeys: String, CodingKey { + case courseUnitProgressEnabled = "course_unit_progress_enabled" + case courseDropdownNavigationEnabled = "course_dropdown_navigation_enabled" + case preLoginExperienceEnabled = "pre_login_experience_enabled" + } + } + + struct Dashboard: Hashable, Sendable, Codable { + let type: String + + enum CodingKeys: String, CodingKey { + case type + } + } + + let id: String + let title: String + let description: String + let api: API + let featureFlags: FeatureFlags + let theme: Theme? + let uiComponents: UIComponents? + let dashboard: Dashboard? + let accentColorHex: String? + let shortDescription: String + let baseURL: URL + let logoURL: URL? + + var accentColor: LMSColor? { + guard let hex = accentColorHex else { return nil } + return LMSColor(hex: hex) + } + + var accentColorDark: LMSColor? { + guard let hex = theme?.accentColorDark else { return nil } + return LMSColor(hex: hex) + } + + /// Returns the best logo URL: uploaded logo takes priority over external URL + var effectiveLogoURL: URL? { + theme?.logoUploadURL ?? logoURL + } + + /// Whether unknown units should be shown in webview instead of blocked + var showUnknownUnitsInWebview: Bool { + featureFlags.unknownUnitsMode == "webview" + } +} + +struct LMSColor: Sendable, Hashable { + let red: Double + let green: Double + let blue: Double + + init?(hex: String) { + let trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) + var value = trimmed + if trimmed.hasPrefix("#") { + value = String(trimmed.dropFirst()) + } + guard value.count == 6, let intValue = Int(value, radix: 16) else { + return nil + } + red = Double((intValue >> 16) & 0xFF) / 255.0 + green = Double((intValue >> 8) & 0xFF) / 255.0 + blue = Double(intValue & 0xFF) / 255.0 + } +} + +struct LMSHistoryItem: Identifiable, Hashable, Sendable { + let id: String + let title: String + let shortDescription: String + let baseURL: URL + let logoURL: URL? + let lastOpenedAt: Date + let payloadData: Data + let isPinned: Bool + + func decodedDetail() -> LMSDetail? { + try? JSONDecoder().decode(LMSDetailDTO.self, from: payloadData).domainModel + } +} + +// MARK: - DTOs used for JSON mocks and persistence + +struct LMSListResponse: Codable { + let items: [LMSSummaryDTO] +} + +struct LMSSummaryDTO: Codable { + let id: String + let title: String + let shortDescription: String + let baseURL: URL + let logoURL: URL? + let accentColor: String? + + enum CodingKeys: String, CodingKey { + case id + case title + case shortDescription = "short_description" + case baseURL = "base_url" + case logoURL = "logo_url" + case accentColor = "accent_color" + } +} + +struct LMSDetailDTO: Codable { + struct APIDTO: Codable { + let hostURL: URL + let feedbackEmail: String + let oauthClientId: String + + enum CodingKeys: String, CodingKey { + case hostURL = "host_url" + case feedbackEmail = "feedback_email" + case oauthClientId = "oauth_client_id" + } + } + + let id: String + let title: String + let description: String + let api: APIDTO + let featureFlags: LMSDetail.FeatureFlags + let theme: LMSDetail.Theme? + let uiComponents: LMSDetail.UIComponents? + let dashboard: LMSDetail.Dashboard? + let accentColor: String? + let shortDescription: String + let baseURL: URL + let logoURL: URL? + + enum CodingKeys: String, CodingKey { + case id + case title + case description + case api + case featureFlags = "feature_flags" + case theme + case uiComponents = "ui_components" + case dashboard + case accentColor = "accent_color" + case shortDescription = "short_description" + case baseURL = "base_url" + case logoURL = "logo_url" + } + + var domainModel: LMSDetail { + LMSDetail( + id: id, + title: title, + description: description, + api: .init( + hostURL: api.hostURL, + feedbackEmail: api.feedbackEmail, + oauthClientId: api.oauthClientId + ), + featureFlags: featureFlags, + theme: theme, + uiComponents: uiComponents, + dashboard: dashboard, + accentColorHex: accentColor, + shortDescription: shortDescription, + baseURL: baseURL, + logoURL: logoURL + ) + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift new file mode 100644 index 000000000..82ac48db2 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift @@ -0,0 +1,79 @@ +import Core +import Foundation + +struct LMSSelectionSnapshot: Codable, Sendable { + let detail: LMSDetailDTO + let appliedAt: Date +} + +protocol LMSOverridesStoreProtocol: Sendable { + func save(detail: LMSDetail, payload: Data, storage: CoreStorage?) throws + func currentSelection() -> LMSDetail? + func clear(storage: CoreStorage?) throws +} + +final class LMSOverridesStore: LMSOverridesStoreProtocol { + enum Keys { + static let selectionPayload = "lmsDirectory.selected_lms_payload" + static let feedbackEmail = "lmsDirectory.selected_feedback_email" + static let oauthClientId = "lmsDirectory.selected_oauth_client_id" + static let accentColor = "lmsDirectory.selected_accent_color" + static let accentColorDark = "lmsDirectory.selected_accent_color_dark" + static let unknownUnitsMode = "lmsDirectory.selected_unknown_units_mode" + static let loginBackgroundURL = "lmsDirectory.selected_login_background_url" + static let logoUploadURL = "lmsDirectory.selected_logo_upload_url" + static let courseUnitProgress = "lmsDirectory.selected_course_unit_progress" + static let courseDropdownNav = "lmsDirectory.selected_course_dropdown_nav" + static let preLoginExperience = "lmsDirectory.selected_pre_login_experience" + static let dashboardType = "lmsDirectory.selected_dashboard_type" + } + + private nonisolated(unsafe) let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func save(detail: LMSDetail, payload: Data, storage: CoreStorage?) throws { + if var storage = storage { + storage.selectedLMSBaseURL = detail.api.hostURL.absoluteString + } + userDefaults.set(payload, forKey: Keys.selectionPayload) + userDefaults.set(detail.api.feedbackEmail, forKey: Keys.feedbackEmail) + userDefaults.set(detail.api.oauthClientId, forKey: Keys.oauthClientId) + userDefaults.set(detail.accentColorHex, forKey: Keys.accentColor) + userDefaults.set(detail.theme?.accentColorDark, forKey: Keys.accentColorDark) + userDefaults.set(detail.featureFlags.unknownUnitsMode ?? "block", forKey: Keys.unknownUnitsMode) + userDefaults.set(detail.theme?.loginBackgroundURL?.absoluteString, forKey: Keys.loginBackgroundURL) + userDefaults.set(detail.effectiveLogoURL?.absoluteString, forKey: Keys.logoUploadURL) + userDefaults.set(detail.uiComponents?.courseUnitProgressEnabled ?? true, forKey: Keys.courseUnitProgress) + userDefaults.set(detail.uiComponents?.courseDropdownNavigationEnabled ?? true, forKey: Keys.courseDropdownNav) + userDefaults.set(detail.uiComponents?.preLoginExperienceEnabled ?? true, forKey: Keys.preLoginExperience) + userDefaults.set(detail.dashboard?.type ?? "gallery", forKey: Keys.dashboardType) + } + + func currentSelection() -> LMSDetail? { + guard + let payload = userDefaults.data(forKey: Keys.selectionPayload), + let dto = try? JSONDecoder().decode(LMSDetailDTO.self, from: payload) + else { + return nil + } + return dto.domainModel + } + + func clear(storage: CoreStorage?) throws { + if var storage = storage { + storage.selectedLMSBaseURL = nil + } + for key in [ + Keys.selectionPayload, Keys.feedbackEmail, Keys.oauthClientId, + Keys.accentColor, Keys.accentColorDark, Keys.unknownUnitsMode, + Keys.loginBackgroundURL, Keys.logoUploadURL, + Keys.courseUnitProgress, Keys.courseDropdownNav, + Keys.preLoginExperience, Keys.dashboardType + ] { + userDefaults.removeObject(forKey: key) + } + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift new file mode 100644 index 000000000..6f008d528 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift @@ -0,0 +1,78 @@ +import Core +import Foundation +import Swinject +import OEXFoundation +import Alamofire + +@MainActor +public protocol LMSSelectionRouting: AnyObject { + func presentDiscovery() + func showLogin() +} + +@MainActor +protocol LMSSelectionCoordinating: Sendable { + func applySelection( + detail: LMSDetail, + payload: Data, + fromHistory: Bool + ) async +} + +@MainActor +final class LMSSelectionCoordinator: LMSSelectionCoordinating { + private let historyStore: LMSHistoryStoreProtocol + private let overridesStore: LMSOverridesStoreProtocol + private let analytics: LMSDirectoryAnalytics + private weak var router: LMSSelectionRouting? + private let coreStorage: CoreStorage? + private let container: Container + + init( + historyStore: LMSHistoryStoreProtocol, + overridesStore: LMSOverridesStoreProtocol, + analytics: LMSDirectoryAnalytics, + router: LMSSelectionRouting?, + coreStorage: CoreStorage?, + container: Container + ) { + self.historyStore = historyStore + self.overridesStore = overridesStore + self.analytics = analytics + self.router = router + self.coreStorage = coreStorage + self.container = container + } + + func applySelection( + detail: LMSDetail, + payload: Data, + fromHistory: Bool + ) async { + do { + try historyStore.unpinAll() + try historyStore.save(detail: detail, payload: payload, pinned: true) + try overridesStore.save(detail: detail, payload: payload, storage: coreStorage) + analytics.selectionMade(id: detail.id, fromHistory: fromHistory) + LMSThemeApplier.applyAccentColor(detail.accentColor, darkColor: detail.accentColorDark) + reRegisterAPI(with: detail.api.hostURL) + await handlePostSelection(for: detail) + } catch { + assertionFailure("Failed to apply LMS selection: \(error)") + } + } + + private func reRegisterAPI(with baseURL: URL) { + container.register(API.self) { r in + API(session: r.resolve(Alamofire.Session.self)!, baseURL: baseURL) + }.inObjectScope(.container) + } + + private func handlePostSelection(for detail: LMSDetail) async { + if detail.featureFlags.preLoginDiscovery { + router?.presentDiscovery() + } else { + router?.showLogin() + } + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift new file mode 100644 index 000000000..d6519278a --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift @@ -0,0 +1,98 @@ +import SwiftUI +import Theme +import UIKit + +enum LMSThemeApplier { + static func applyAccentColor(_ color: LMSColor?, darkColor: LMSColor? = nil) { + guard let color else { + Theme.Colors.update() + Theme.UIColors.update() + return + } + + let base = color.uiColor + let lightAccent = base.ensuringBrightness(min: 0.35) + let darkAccent: UIColor + if let darkColor { + darkAccent = darkColor.uiColor + } else { + darkAccent = base + .adjustingSaturation(multiplier: 0.8) + .ensuringBrightness(min: 0.45, max: 0.85) + } + + let accentDynamicColor = dynamicColor(light: lightAccent, dark: darkAccent) + let accentDynamicUIColor = dynamicUIColor(light: lightAccent, dark: darkAccent) + + // Upstream Theme.Colors.update(...) defaults every parameter, so we override + // only the accent-driven colors and leave everything else at the stock theme. + Theme.Colors.update( + accentColor: accentDynamicColor, + accentXColor: accentDynamicColor, + secondaryButtonBorderColor: accentDynamicColor, + secondaryButtonTextColor: accentDynamicColor, + toggleSwitchColor: accentDynamicColor, + infoColor: accentDynamicColor + ) + + Theme.UIColors.update( + accentColor: accentDynamicUIColor, + accentXColor: accentDynamicUIColor + ) + } + + private static func dynamicColor(light: UIColor, dark: UIColor) -> Color { + Color(dynamicUIColor(light: light, dark: dark)) + } + + private static func dynamicUIColor(light: UIColor, dark: UIColor) -> UIColor { + UIColor { trait in + trait.userInterfaceStyle == .dark ? dark : light + } + } +} + +private extension LMSColor { + var uiColor: UIColor { + UIColor(red: red, green: green, blue: blue, alpha: 1) + } +} + +private extension UIColor { + func ensuringBrightness(min: CGFloat? = nil, max: CGFloat? = nil) -> UIColor { + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + guard getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) else { + return self + } + if let min, brightness < min { + brightness = min + } + if let max, brightness > max { + brightness = max + } + return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: alpha) + } + + func adjustingSaturation(multiplier: CGFloat) -> UIColor { + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + guard getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) else { + return self + } + saturation = min(max(saturation * multiplier, 0), 1) + return UIColor(hue: hue, saturation: saturation, brightness: brightness, alpha: alpha) + } + + func blending(with color: UIColor, amount: CGFloat) -> UIColor { + let amount = min(max(amount, 0), 1) + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 + getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + color.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + return UIColor( + red: r1 * (1 - amount) + r2 * amount, + green: g1 * (1 - amount) + g2 * amount, + blue: b1 * (1 - amount) + b2 * amount, + alpha: a1 * (1 - amount) + a2 * amount + ) + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/RemoteLMSDirectoryService.swift b/Authorization/Authorization/Presentation/TenantPicker/RemoteLMSDirectoryService.swift new file mode 100644 index 000000000..8f3f1aa2c --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/RemoteLMSDirectoryService.swift @@ -0,0 +1,134 @@ +import Core +import Foundation + +final class RemoteLMSDirectoryService: LMSDirectoryService { + + private let baseURL: URL + private let session: URLSession + private let connectivity: ConnectivityProtocol + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + init( + baseURL: URL, + connectivity: ConnectivityProtocol, + session: URLSession = .shared + ) { + self.baseURL = baseURL + self.connectivity = connectivity + self.session = session + } + + func search(query: String) async throws -> [LMSSearchResult] { + try await ensureOnline() + + var components = URLComponents( + url: baseURL.appendingPathComponent("/api/v1/directory"), + resolvingAgainstBaseURL: false + )! + if !query.isEmpty { + components.queryItems = [URLQueryItem(name: "q", value: query)] + } + + let (data, response) = try await session.data(from: components.url!) + try validateResponse(response) + + do { + let listResponse = try decoder.decode(LMSListResponse.self, from: data) + return listResponse.items.map { + LMSSearchResult( + id: $0.id, + title: $0.title, + shortDescription: $0.shortDescription, + baseURL: $0.baseURL, + logoURL: $0.logoURL, + accentColorHex: $0.accentColor + ) + } + } catch { + throw LMSDirectoryError.decodingFailed + } + } + + func fetchDetails(id: String) async throws -> LMSDetail { + try await ensureOnline() + + let url = baseURL.appendingPathComponent("/api/v1/directory/\(id)") + let (data, response) = try await session.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LMSDirectoryError.decodingFailed + } + + if httpResponse.statusCode == 404 { + throw LMSDirectoryError.notFound + } + + try validateResponse(response) + + do { + let dto = try decoder.decode(LMSDetailDTO.self, from: data) + return dto.domainModel + } catch { + throw LMSDirectoryError.decodingFailed + } + } + + func fetchConfig() async throws -> LMSRegistryConfig { + try await ensureOnline() + let url = baseURL.appendingPathComponent("/api/v1/config") + let (data, response) = try await session.data(from: url) + try validateResponse(response) + do { + return try decoder.decode(LMSConfigDTO.self, from: data).domainModel + } catch { + throw LMSDirectoryError.decodingFailed + } + } + + func fetchFeatured() async throws -> [LMSSearchResult] { + try await ensureOnline() + var components = URLComponents( + url: baseURL.appendingPathComponent("/api/v1/directory"), + resolvingAgainstBaseURL: false + )! + components.queryItems = [URLQueryItem(name: "featured", value: "true")] + + let (data, response) = try await session.data(from: components.url!) + try validateResponse(response) + do { + let listResponse = try decoder.decode(LMSListResponse.self, from: data) + return listResponse.items.map { + LMSSearchResult( + id: $0.id, + title: $0.title, + shortDescription: $0.shortDescription, + baseURL: $0.baseURL, + logoURL: $0.logoURL, + accentColorHex: $0.accentColor + ) + } + } catch { + throw LMSDirectoryError.decodingFailed + } + } + + // MARK: - Private + + private func ensureOnline() async throws { + let isOnline = await MainActor.run { connectivity.isInternetAvaliable } + guard isOnline else { + throw LMSDirectoryError.offline + } + } + + private func validateResponse(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw LMSDirectoryError.notFound + } + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/lms_mock_data.json b/Authorization/Authorization/Presentation/TenantPicker/lms_mock_data.json new file mode 100644 index 000000000..c8337d85b --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/lms_mock_data.json @@ -0,0 +1,192 @@ +[ + { + "id": "lms_mit_openlearning", + "title": "MIT Open Learning", + "description": "Digital and professional education programs from the Massachusetts Institute of Technology.", + "short_description": "MIT Open Learning programs.", + "base_url": "https://axim-mobile-dev.raccoongang.net", + "logo_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/MIT_Logo_New.svg/2560px-MIT_Logo_New.svg.png", + "api": { + "host_url": "https://axim-mobile-dev.raccoongang.net", + "feedback_email": "openlearning-support@mit.edu", + "oauth_client_id": "bHvM10KwHSrM5Gk6uoRBaS3KFRhjSFHfGGfYP4SP" + }, + "feature_flags": { + "pre_login_discovery": true, + "offline_downloads": true, + "smart_push_automation": true + }, + "accent_color": "#A31F34" + }, + { + "id": "lms_stanford_online", + "title": "Stanford Online", + "description": "Courses and certificates from Stanford University delivered online.", + "short_description": "Stanford University online offerings.", + "base_url": "https://online.stanford.edu", + "logo_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Stanford_Cardinal_logo.svg/120px-Stanford_Cardinal_logo.svg.png?20170714202931", + "api": { + "host_url": "https://online.stanford.edu", + "feedback_email": "support@online.stanford.edu", + "oauth_client_id": "stanford.online.mobile" + }, + "feature_flags": { + "pre_login_discovery": true, + "offline_downloads": true, + "smart_push_automation": false + }, + "accent_color": "#8C1515" + }, + { + "id": "lms_harvard_extension", + "title": "Harvard Extension School", + "description": "Flexible degrees and certificates from Harvard University's Division of online Continuing Education.", + "short_description": "Harvard Extension online and hybrid learning.", + "base_url": "https://extension.harvard.edu", + "logo_url": "https://extension.harvard.edu/wp-content/uploads/sites/8/2023/02/Social-Media-Profile-Picture-.png", + "api": { + "host_url": "https://extension.harvard.edu", + "feedback_email": "support@extension.harvard.edu", + "oauth_client_id": "harvard.extension.mobile" + }, + "feature_flags": { + "pre_login_discovery": false, + "offline_downloads": true, + "smart_push_automation": false + }, + "accent_color": "#A51C30" + }, + { + "id": "lms_oxford_conted", + "title": "Oxford Continuing Education", + "description": "Short courses, certificates, and online programs from the University of Oxford Department for Continuing Education.", + "short_description": "University of Oxford continuing education.", + "base_url": "https://www.conted.ox.ac.uk", + "logo_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/ff/Oxford-University-Circlet.svg/330px-Oxford-University-Circlet.svg.png", + "api": { + "host_url": "https://www.conted.ox.ac.uk", + "feedback_email": "support@conted.ox.ac.uk", + "oauth_client_id": "oxford.conted.mobile" + }, + "feature_flags": { + "pre_login_discovery": true, + "offline_downloads": true, + "smart_push_automation": true + }, + "accent_color": "#002147" + }, + { + "id": "lms_cambridge_ice", + "title": "Cambridge ICE", + "description": "Institute of online Continuing Education at the University of Cambridge delivering part-time and online study.", + "short_description": "University of Cambridge ICE courses.", + "base_url": "https://www.ice.cam.ac.uk", + "logo_url": "https://upload.wikimedia.org/wikipedia/en/f/fb/Camb_uni_ihc.jpg", + "api": { + "host_url": "https://www.ice.cam.ac.uk", + "feedback_email": "support@ice.cam.ac.uk", + "oauth_client_id": "cambridge.ice.mobile" + }, + "feature_flags": { + "pre_login_discovery": true, + "offline_downloads": true, + "smart_push_automation": false + }, + "accent_color": "#0072CF" + }, + { + "id": "lms_imperial_digital", + "title": "Imperial Digital Learning", + "description": "Online courses and microcredentials from Imperial College London.", + "short_description": "Imperial College London online learning.", + "base_url": "https://axim-mobile-dev.raccoongang.net", + "logo_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c5/Shield_of_Imperial_College_London.svg/330px-Shield_of_Imperial_College_London.svg.png", + "api": { + "host_url": "https://axim-mobile-dev.raccoongang.net", + "feedback_email": "digitallearning@imperial.ac.uk", + "oauth_client_id": "bHvM10KwHSrM5Gk6uoRBaS3KFRhjSFHfGGfYP4SP" + }, + "feature_flags": { + "pre_login_discovery": true, + "offline_downloads": true, + "smart_push_automation": true + }, + "accent_color": "#003E74" + }, + { + "id": "lms_anu_online", + "title": "ANU Online", + "description": "Online degrees and professional courses from the Australian National University.", + "short_description": "ANU digital programs.", + "base_url": "https://www.anu.edu.au/study/anu-online", + "logo_url": "https://upload.wikimedia.org/wikipedia/en/thumb/5/5f/Australian_National_University_coat_of_arms.svg/500px-Australian_National_University_coat_of_arms.svg.png", + "api": { + "host_url": "https://www.anu.edu.au", + "feedback_email": "support@anu.edu.au", + "oauth_client_id": "anu.online.mobile" + }, + "feature_flags": { + "pre_login_discovery": false, + "offline_downloads": true, + "smart_push_automation": true + }, + "accent_color": "#B9975B" + }, + { + "id": "lms_eth_conted", + "title": "ETH Zurich Continuing Education", + "description": "Lifelong online learning and professional programs from ETH Zurich.", + "short_description": "ETH Zurich continuing education.", + "base_url": "https://ethz.ch/en/continuing-education.html", + "logo_url": "https://www.designyourway.net/blog/wp-content/uploads/2024/04/the-meaning-behind-the-ETH-zurich-logo.jpg", + "api": { + "host_url": "https://ethz.ch", + "feedback_email": "info@ethz.ch", + "oauth_client_id": "eth.conted.mobile" + }, + "feature_flags": { + "pre_login_discovery": true, + "offline_downloads": true, + "smart_push_automation": false + }, + "accent_color": "#0F6CB6" + }, + { + "id": "lms_nus_lifelong", + "title": "NUS Lifelong Learning", + "description": "Continuing online education and SkillsFuture programs from the National University of Singapore.", + "short_description": "NUS lifelong learning programs.", + "base_url": "https://www.nus.edu.sg/lifelonglearning", + "logo_url": "https://scale.nus.edu.sg/images/default-source/cet-2022/nus-l-3-for-alumni-(2023-new).png?sfvrsn=e93baa0e_4&MaxWidth=150&MaxHeight=150&ScaleUp=false&Quality=High&Method=ResizeFitToAreaArguments&Signature=060DFF94F7A1B4E14EF530C60F2FEECCCAB52ABF", + "api": { + "host_url": "https://www.nus.edu.sg", + "feedback_email": "lifelonglearning@nus.edu.sg", + "oauth_client_id": "nus.lifelong.mobile" + }, + "feature_flags": { + "pre_login_discovery": true, + "offline_downloads": true, + "smart_push_automation": false + }, + "accent_color": "#003D7C" + }, + { + "id": "lms_uoft_scs", + "title": "UofT School of Continuing Studies", + "description": "Certificates and professional online learning from the University of Toronto School of Continuing Studies.", + "short_description": "University of Toronto SCS online.", + "base_url": "https://learn.utoronto.ca", + "logo_url": "https://upload.wikimedia.org/wikipedia/en/thumb/0/04/Utoronto_coa.svg/500px-Utoronto_coa.svg.png", + "api": { + "host_url": "https://learn.utoronto.ca", + "feedback_email": "support@utoronto.ca", + "oauth_client_id": "utoronto.scs.mobile" + }, + "feature_flags": { + "pre_login_discovery": true, + "offline_downloads": true, + "smart_push_automation": true + }, + "accent_color": "#002A5C" + } +] diff --git a/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift b/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift index bf2c1a9ae..204ecad51 100644 --- a/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift +++ b/Authorization/AuthorizationTests/Generated/AuthorizationMocks.generated.swift @@ -17,7 +17,7 @@ import ZipArchive public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { public init() { } - public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { + public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, lmsDirectory: LMSDirectoryConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { self.baseURL = baseURL self.baseSSOURL = baseSSOURL self.ssoFinishedURL = ssoFinishedURL @@ -37,6 +37,7 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { self._features = features self._theme = theme self._uiComponents = uiComponents + self._lmsDirectory = lmsDirectory self._discovery = discovery self._dashboard = dashboard self._braze = braze @@ -138,6 +139,12 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { } + private var _lmsDirectory: LMSDirectoryConfig! + public var lmsDirectory: LMSDirectoryConfig { + get { return _lmsDirectory } + set { _lmsDirectory = newValue } + } + private var _uiComponents: UIComponentsConfig! public var uiComponents: UIComponentsConfig { get { return _uiComponents } @@ -257,7 +264,7 @@ public final class CoreAnalyticsMock: CoreAnalytics { public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public init() { } - public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false) { + public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false, selectedLMSBaseURL: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.pushToken = pushToken @@ -273,6 +280,7 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { self.lastUsedSocialAuth = lastUsedSocialAuth self.latestAvailableAppVersion = latestAvailableAppVersion self.updateAppRequired = updateAppRequired + self.selectedLMSBaseURL = selectedLMSBaseURL } @@ -321,6 +329,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public private(set) var updateAppRequiredSetCallCount = 0 public var updateAppRequired: Bool = false { didSet { updateAppRequiredSetCallCount += 1 } } + public private(set) var selectedLMSBaseURLSetCallCount = 0 + public var selectedLMSBaseURL: String? = nil { didSet { selectedLMSBaseURLSetCallCount += 1 } } + private let clearState = MockoloMutex(MockoloHandlerState ()>()) public var clearCallCount: Int { return clearState.withLock(\.callCount) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 150c7377c..699a23327 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -136,6 +136,7 @@ A5D4B3DE2CDD0A9700688951 /* SecureInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4B3DD2CDD0A9700688951 /* SecureInputView.swift */; }; A5D56C222E9F4C44004BE2F6 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5BBFF32E24236500D51C92 /* ColorExtension.swift */; }; A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */; }; + B8AF4F47FCE96744B4AD9F7E /* LMSDirectoryConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9AF280F7F4D9CFF6F315906 /* LMSDirectoryConfig.swift */; }; BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */; }; BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */; }; BA593F1C2AF8E498009ADB51 /* ScrollSlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */; }; @@ -393,6 +394,7 @@ E8D9725130C85DA55AD474A4 /* Pods-CoreTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.debug.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.debug.xcconfig"; sourceTree = ""; }; F4E50CE1DB6AA77E9B5D09EF /* Pods-App-Core-CoreTests.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.debugprod.xcconfig"; sourceTree = ""; }; F7ED6F0C276DBD2F1BA38987 /* Pods-CoreTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.release.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.release.xcconfig"; sourceTree = ""; }; + F9AF280F7F4D9CFF6F315906 /* LMSDirectoryConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LMSDirectoryConfig.swift; sourceTree = ""; }; FB6C49AC95A27A1222AD0F06 /* Pods-App-Core-CoreTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releasestage.xcconfig"; sourceTree = ""; }; FD97820E148E423964AC0CAB /* Pods-CoreTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.releasedev.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.releasedev.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -903,6 +905,7 @@ A53A32342B233DEC005FE38A /* ThemeConfig.swift */, E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */, CE38BBD62D9D8294002CD276 /* ExperimentalFeaturesConfig.swift */, + F9AF280F7F4D9CFF6F315906 /* LMSDirectoryConfig.swift */, ); path = Config; sourceTree = ""; @@ -1311,6 +1314,7 @@ 0254D1912BCD699F000CDE89 /* RefreshProgressView.swift in Sources */, CED42FFC2D099F0400C7AD89 /* DownloadManagerMock.swift in Sources */, 02066B482906F73400F4307E /* PickerMenu.swift in Sources */, + B8AF4F47FCE96744B4AD9F7E /* LMSDirectoryConfig.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 8d0c4b9be..a424c9e40 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -28,6 +28,7 @@ public protocol ConfigProtocol: Sendable { var features: FeaturesConfig { get } var theme: ThemeConfig { get } var uiComponents: UIComponentsConfig { get } + var lmsDirectory: LMSDirectoryConfig { get } var discovery: DiscoveryConfig { get } var dashboard: DashboardConfig { get } var braze: BrazeConfig { get } @@ -119,6 +120,14 @@ public class Config: @unchecked Sendable { extension Config: ConfigProtocol { public var baseURL: URL { + // LMS Directory: when the feature is on and the learner picked a platform, + // the whole app talks to that LMS. Off (default) → stock single-tenant host. + if lmsDirectory.enabled, + let selected = UserDefaults.standard.string(forKey: "selectedLMSBaseURL"), + !selected.isEmpty, + let selectedURL = URL(string: selected) { + return selectedURL + } guard let urlString = string(for: ConfigKeys.baseURL.rawValue), let url = URL(string: urlString) else { fatalError("Unable to find base url in config.") diff --git a/Core/Core/Configuration/Config/LMSDirectoryConfig.swift b/Core/Core/Configuration/Config/LMSDirectoryConfig.swift new file mode 100644 index 000000000..2895781a6 --- /dev/null +++ b/Core/Core/Configuration/Config/LMSDirectoryConfig.swift @@ -0,0 +1,45 @@ +// +// LMSDirectoryConfig.swift +// Core +// +// Feature flag for the LMS Directory ("pick / access any Open edX LMS") feature. +// When ENABLED is false the app behaves exactly like a stock single-tenant build. +// + +import Foundation +import OEXFoundation + +private enum Keys: String, RawStringExtractable { + case enabled = "ENABLED" + case directoryURL = "DIRECTORY_URL" + case directoryMode = "DIRECTORY_MODE" +} + +public class LMSDirectoryConfig: NSObject { + /// Master gate. When false the feature is completely inert. + public var enabled: Bool + /// Base URL of the LMS registry that serves the catalog, e.g. https://registry.example.com + public var directoryURL: String + /// Optional client override: "" | "search" | "curated". The registry's + /// /api/v1/config response is authoritative; this only forces a mode client-side. + public var directoryMode: String + + /// The catalog is only reachable when the feature is on and a registry URL is set. + public var isDirectoryReachable: Bool { + enabled && !directoryURL.trimmingCharacters(in: .whitespaces).isEmpty + } + + init(dictionary: [String: Any]) { + enabled = dictionary[Keys.enabled] as? Bool ?? false + directoryURL = dictionary[Keys.directoryURL] as? String ?? "" + directoryMode = dictionary[Keys.directoryMode] as? String ?? "" + super.init() + } +} + +private let key = "LMS_DIRECTORY" +extension Config { + public var lmsDirectory: LMSDirectoryConfig { + return LMSDirectoryConfig(dictionary: properties[key] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index 32b511f7b..65262e496 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -24,6 +24,9 @@ public protocol CoreStorage: Sendable { var lastUsedSocialAuth: String? {get set} var latestAvailableAppVersion: String? {get set} var updateAppRequired: Bool {get set} + /// Base URL of the LMS the learner selected via the LMS Directory feature. + /// nil when no selection (stock single-tenant behaviour / after logout). + var selectedLMSBaseURL: String? {get set} func clear() } @@ -44,8 +47,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public var lastUsedSocialAuth: String? public var latestAvailableAppVersion: String? public var updateAppRequired: Bool = false + public var selectedLMSBaseURL: String? public func clear() {} - + public init() {} } #endif diff --git a/Core/CoreTests/Generated/CoreMocks.generated.swift b/Core/CoreTests/Generated/CoreMocks.generated.swift index 96d0533ea..67abd55ef 100644 --- a/Core/CoreTests/Generated/CoreMocks.generated.swift +++ b/Core/CoreTests/Generated/CoreMocks.generated.swift @@ -16,7 +16,7 @@ import ZipArchive public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { public init() { } - public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { + public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, lmsDirectory: LMSDirectoryConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { self.baseURL = baseURL self.baseSSOURL = baseSSOURL self.ssoFinishedURL = ssoFinishedURL @@ -36,6 +36,7 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { self._features = features self._theme = theme self._uiComponents = uiComponents + self._lmsDirectory = lmsDirectory self._discovery = discovery self._dashboard = dashboard self._braze = braze @@ -137,6 +138,12 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { } + private var _lmsDirectory: LMSDirectoryConfig! + public var lmsDirectory: LMSDirectoryConfig { + get { return _lmsDirectory } + set { _lmsDirectory = newValue } + } + private var _uiComponents: UIComponentsConfig! public var uiComponents: UIComponentsConfig { get { return _uiComponents } @@ -256,7 +263,7 @@ public final class CoreAnalyticsMock: CoreAnalytics { public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public init() { } - public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false) { + public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false, selectedLMSBaseURL: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.pushToken = pushToken @@ -272,6 +279,7 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { self.lastUsedSocialAuth = lastUsedSocialAuth self.latestAvailableAppVersion = latestAvailableAppVersion self.updateAppRequired = updateAppRequired + self.selectedLMSBaseURL = selectedLMSBaseURL } @@ -320,6 +328,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public private(set) var updateAppRequiredSetCallCount = 0 public var updateAppRequired: Bool = false { didSet { updateAppRequiredSetCallCount += 1 } } + public private(set) var selectedLMSBaseURLSetCallCount = 0 + public var selectedLMSBaseURL: String? = nil { didSet { selectedLMSBaseURLSetCallCount += 1 } } + private let clearState = MockoloMutex(MockoloHandlerState ()>()) public var clearCallCount: Int { return clearState.withLock(\.callCount) diff --git a/Course/CourseTests/Generated/CourseMocks.generated.swift b/Course/CourseTests/Generated/CourseMocks.generated.swift index 176f2245b..e705eef64 100644 --- a/Course/CourseTests/Generated/CourseMocks.generated.swift +++ b/Course/CourseTests/Generated/CourseMocks.generated.swift @@ -18,7 +18,7 @@ import ZipArchive public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { public init() { } - public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { + public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, lmsDirectory: LMSDirectoryConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { self.baseURL = baseURL self.baseSSOURL = baseSSOURL self.ssoFinishedURL = ssoFinishedURL @@ -38,6 +38,7 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { self._features = features self._theme = theme self._uiComponents = uiComponents + self._lmsDirectory = lmsDirectory self._discovery = discovery self._dashboard = dashboard self._braze = braze @@ -139,6 +140,12 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { } + private var _lmsDirectory: LMSDirectoryConfig! + public var lmsDirectory: LMSDirectoryConfig { + get { return _lmsDirectory } + set { _lmsDirectory = newValue } + } + private var _uiComponents: UIComponentsConfig! public var uiComponents: UIComponentsConfig { get { return _uiComponents } @@ -258,7 +265,7 @@ public final class CoreAnalyticsMock: CoreAnalytics { public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public init() { } - public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false) { + public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false, selectedLMSBaseURL: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.pushToken = pushToken @@ -274,6 +281,7 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { self.lastUsedSocialAuth = lastUsedSocialAuth self.latestAvailableAppVersion = latestAvailableAppVersion self.updateAppRequired = updateAppRequired + self.selectedLMSBaseURL = selectedLMSBaseURL } @@ -322,6 +330,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public private(set) var updateAppRequiredSetCallCount = 0 public var updateAppRequired: Bool = false { didSet { updateAppRequiredSetCallCount += 1 } } + public private(set) var selectedLMSBaseURLSetCallCount = 0 + public var selectedLMSBaseURL: String? = nil { didSet { selectedLMSBaseURLSetCallCount += 1 } } + private let clearState = MockoloMutex(MockoloHandlerState ()>()) public var clearCallCount: Int { return clearState.withLock(\.callCount) diff --git a/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift b/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift index 6ca250c64..76d1f85e8 100644 --- a/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift +++ b/Dashboard/DashboardTests/Generated/DashboardMocks.generated.swift @@ -17,7 +17,7 @@ import ZipArchive public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { public init() { } - public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { + public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, lmsDirectory: LMSDirectoryConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { self.baseURL = baseURL self.baseSSOURL = baseSSOURL self.ssoFinishedURL = ssoFinishedURL @@ -37,6 +37,7 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { self._features = features self._theme = theme self._uiComponents = uiComponents + self._lmsDirectory = lmsDirectory self._discovery = discovery self._dashboard = dashboard self._braze = braze @@ -138,6 +139,12 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { } + private var _lmsDirectory: LMSDirectoryConfig! + public var lmsDirectory: LMSDirectoryConfig { + get { return _lmsDirectory } + set { _lmsDirectory = newValue } + } + private var _uiComponents: UIComponentsConfig! public var uiComponents: UIComponentsConfig { get { return _uiComponents } @@ -257,7 +264,7 @@ public final class CoreAnalyticsMock: CoreAnalytics { public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public init() { } - public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false) { + public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false, selectedLMSBaseURL: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.pushToken = pushToken @@ -273,6 +280,7 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { self.lastUsedSocialAuth = lastUsedSocialAuth self.latestAvailableAppVersion = latestAvailableAppVersion self.updateAppRequired = updateAppRequired + self.selectedLMSBaseURL = selectedLMSBaseURL } @@ -321,6 +329,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public private(set) var updateAppRequiredSetCallCount = 0 public var updateAppRequired: Bool = false { didSet { updateAppRequiredSetCallCount += 1 } } + public private(set) var selectedLMSBaseURLSetCallCount = 0 + public var selectedLMSBaseURL: String? = nil { didSet { selectedLMSBaseURLSetCallCount += 1 } } + private let clearState = MockoloMutex(MockoloHandlerState ()>()) public var clearCallCount: Int { return clearState.withLock(\.callCount) diff --git a/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift b/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift index ab3d0118a..67e6187d1 100644 --- a/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift +++ b/Discovery/DiscoveryTests/Generated/DiscoveryMocks.generated.swift @@ -17,7 +17,7 @@ import ZipArchive public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { public init() { } - public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { + public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, lmsDirectory: LMSDirectoryConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { self.baseURL = baseURL self.baseSSOURL = baseSSOURL self.ssoFinishedURL = ssoFinishedURL @@ -37,6 +37,7 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { self._features = features self._theme = theme self._uiComponents = uiComponents + self._lmsDirectory = lmsDirectory self._discovery = discovery self._dashboard = dashboard self._braze = braze @@ -138,6 +139,12 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { } + private var _lmsDirectory: LMSDirectoryConfig! + public var lmsDirectory: LMSDirectoryConfig { + get { return _lmsDirectory } + set { _lmsDirectory = newValue } + } + private var _uiComponents: UIComponentsConfig! public var uiComponents: UIComponentsConfig { get { return _uiComponents } @@ -257,7 +264,7 @@ public final class CoreAnalyticsMock: CoreAnalytics { public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public init() { } - public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false) { + public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false, selectedLMSBaseURL: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.pushToken = pushToken @@ -273,6 +280,7 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { self.lastUsedSocialAuth = lastUsedSocialAuth self.latestAvailableAppVersion = latestAvailableAppVersion self.updateAppRequired = updateAppRequired + self.selectedLMSBaseURL = selectedLMSBaseURL } @@ -321,6 +329,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public private(set) var updateAppRequiredSetCallCount = 0 public var updateAppRequired: Bool = false { didSet { updateAppRequiredSetCallCount += 1 } } + public private(set) var selectedLMSBaseURLSetCallCount = 0 + public var selectedLMSBaseURL: String? = nil { didSet { selectedLMSBaseURLSetCallCount += 1 } } + private let clearState = MockoloMutex(MockoloHandlerState ()>()) public var clearCallCount: Int { return clearState.withLock(\.callCount) diff --git a/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift b/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift index 799542908..1bf9525f8 100644 --- a/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift +++ b/Discussion/DiscussionTests/Generated/DiscussionMocks.generated.swift @@ -17,7 +17,7 @@ import ZipArchive public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { public init() { } - public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { + public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, lmsDirectory: LMSDirectoryConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { self.baseURL = baseURL self.baseSSOURL = baseSSOURL self.ssoFinishedURL = ssoFinishedURL @@ -37,6 +37,7 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { self._features = features self._theme = theme self._uiComponents = uiComponents + self._lmsDirectory = lmsDirectory self._discovery = discovery self._dashboard = dashboard self._braze = braze @@ -138,6 +139,12 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { } + private var _lmsDirectory: LMSDirectoryConfig! + public var lmsDirectory: LMSDirectoryConfig { + get { return _lmsDirectory } + set { _lmsDirectory = newValue } + } + private var _uiComponents: UIComponentsConfig! public var uiComponents: UIComponentsConfig { get { return _uiComponents } @@ -257,7 +264,7 @@ public final class CoreAnalyticsMock: CoreAnalytics { public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public init() { } - public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false) { + public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false, selectedLMSBaseURL: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.pushToken = pushToken @@ -273,6 +280,7 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { self.lastUsedSocialAuth = lastUsedSocialAuth self.latestAvailableAppVersion = latestAvailableAppVersion self.updateAppRequired = updateAppRequired + self.selectedLMSBaseURL = selectedLMSBaseURL } @@ -321,6 +329,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public private(set) var updateAppRequiredSetCallCount = 0 public var updateAppRequired: Bool = false { didSet { updateAppRequiredSetCallCount += 1 } } + public private(set) var selectedLMSBaseURLSetCallCount = 0 + public var selectedLMSBaseURL: String? = nil { didSet { selectedLMSBaseURLSetCallCount += 1 } } + private let clearState = MockoloMutex(MockoloHandlerState ()>()) public var clearCallCount: Int { return clearState.withLock(\.callCount) diff --git a/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift b/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift index 69f85c6fa..d53ece481 100644 --- a/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift +++ b/Downloads/DownloadsTests/Generated/DownloadsMocks.generated.swift @@ -17,7 +17,7 @@ import ZipArchive public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { public init() { } - public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { + public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, lmsDirectory: LMSDirectoryConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { self.baseURL = baseURL self.baseSSOURL = baseSSOURL self.ssoFinishedURL = ssoFinishedURL @@ -37,6 +37,7 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { self._features = features self._theme = theme self._uiComponents = uiComponents + self._lmsDirectory = lmsDirectory self._discovery = discovery self._dashboard = dashboard self._braze = braze @@ -138,6 +139,12 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { } + private var _lmsDirectory: LMSDirectoryConfig! + public var lmsDirectory: LMSDirectoryConfig { + get { return _lmsDirectory } + set { _lmsDirectory = newValue } + } + private var _uiComponents: UIComponentsConfig! public var uiComponents: UIComponentsConfig { get { return _uiComponents } @@ -257,7 +264,7 @@ public final class CoreAnalyticsMock: CoreAnalytics { public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public init() { } - public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false) { + public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false, selectedLMSBaseURL: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.pushToken = pushToken @@ -273,6 +280,7 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { self.lastUsedSocialAuth = lastUsedSocialAuth self.latestAvailableAppVersion = latestAvailableAppVersion self.updateAppRequired = updateAppRequired + self.selectedLMSBaseURL = selectedLMSBaseURL } @@ -321,6 +329,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public private(set) var updateAppRequiredSetCallCount = 0 public var updateAppRequired: Bool = false { didSet { updateAppRequiredSetCallCount += 1 } } + public private(set) var selectedLMSBaseURLSetCallCount = 0 + public var selectedLMSBaseURL: String? = nil { didSet { selectedLMSBaseURLSetCallCount += 1 } } + private let clearState = MockoloMutex(MockoloHandlerState ()>()) public var clearCallCount: Int { return clearState.withLock(\.callCount) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 51bfc2b81..b2a285d24 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */; }; 07D5DA3E28D075AB00752FD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */; }; 149FF39E2B9F1AB50034B33F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 149FF39C2B9F1AB50034B33F /* LaunchScreen.storyboard */; }; + 222D6F5B8D6BBD02F3E0AAA7 /* LMSDirectoryRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070574B3ABFF71F1AF727DDE /* LMSDirectoryRouter.swift */; }; 705A908842AAAFC361CD9D52 /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 58FAA9E3ECC93D0E638D877D /* Pods_App_OpenEdX.framework */; }; A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668A2B613ED10024680B /* PushNotificationsManager.swift */; }; A500668D2B6143000024680B /* FCMProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668C2B6143000024680B /* FCMProvider.swift */; }; @@ -123,6 +124,7 @@ 02ED50DB29A6600B008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = uk; path = uk.lproj/languages.json; sourceTree = ""; }; 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenAnalytics.swift; sourceTree = ""; }; 065275362BB1B4070093BCCA /* PipManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PipManager.swift; sourceTree = ""; }; + 070574B3ABFF71F1AF727DDE /* LMSDirectoryRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LMSDirectoryRouter.swift; sourceTree = ""; }; 071009C828D1DB3F00344290 /* ScreenAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenAssembly.swift; sourceTree = ""; }; 0727878D28D347C7002E9142 /* MainScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenView.swift; sourceTree = ""; }; 072787B028D34D83002E9142 /* Discovery.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discovery.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -270,6 +272,7 @@ 02ED50DA29A66007008341CD /* languages.json */, 02ED50D629A6554E008341CD /* сountries.json */, 0770DE6628D0BCC7006D8A5D /* Localizable.strings */, + 070574B3ABFF71F1AF727DDE /* LMSDirectoryRouter.swift */, ); path = OpenEdX; sourceTree = ""; @@ -599,6 +602,7 @@ A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, 022213D22C0E08E500B917E6 /* ProfilePersistence.swift in Sources */, A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, + 222D6F5B8D6BBD02F3E0AAA7 /* LMSDirectoryRouter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index f5ad8d591..2954135e2 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -10,6 +10,7 @@ import Core import OEXFoundation import Swinject import Profile +import Authorization import GoogleSignIn import FacebookCore import MSAL @@ -50,7 +51,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if let config = Container.shared.resolve(ConfigProtocol.self) { Theme.Shapes.isRoundedCorners = config.theme.isRoundedCorners Theme.Shapes.buttonCornersRadius = config.theme.buttonCornersRadius - + + // LMS Directory: flag-gated. When on, the app can browse/pick any Open edX + // platform, re-theme to it, and route back to sign-in after selection. + if config.lmsDirectory.enabled { + Container.shared.register(LMSSelectionRouting.self) { _ in + LMSDirectoryRouter() + }.inObjectScope(.container) + LMSDirectoryFeature.register(directoryBaseURL: config.lmsDirectory.directoryURL) + } + if config.facebook.enabled { ApplicationDelegate.shared.application( application, diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 968b8ea9c..19ebbf261 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -106,6 +106,19 @@ public final class AppStorage: CoreStorage, } } + public var selectedLMSBaseURL: String? { + get { + return userDefaults.string(forKey: KEY_SELECTED_LMS_BASE_URL) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_SELECTED_LMS_BASE_URL) + } else { + userDefaults.removeObject(forKey: KEY_SELECTED_LMS_BASE_URL) + } + } + } + public var reviewLastShownVersion: String? { get { return userDefaults.string(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) @@ -391,6 +404,7 @@ public final class AppStorage: CoreStorage, accessToken = nil refreshToken = nil cookiesDate = nil + selectedLMSBaseURL = nil user = nil userProfile = nil // delete all cookies @@ -405,6 +419,7 @@ public final class AppStorage: CoreStorage, private let KEY_REFRESH_TOKEN = "refreshToken" private let KEY_PUSH_TOKEN = "pushToken" private let KEY_COOKIES_DATE = "cookiesDate" + private let KEY_SELECTED_LMS_BASE_URL = "selectedLMSBaseURL" private let KEY_USER_PROFILE = "userProfile" private let KEY_USER = "refreshToken" private let KEY_SETTINGS = "userSettings" diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index e9bd32e58..2e121f038 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -35,6 +35,8 @@ NSCalendarsFullAccessUsageDescription We would like to utilize your calendar list to subscribe you to your personalized calendar for this course. + NSCameraUsageDescription + The camera is used to scan a QR code that opens your learning platform. UIAppFonts UIBackgroundModes diff --git a/OpenEdX/LMSDirectoryRouter.swift b/OpenEdX/LMSDirectoryRouter.swift new file mode 100644 index 000000000..5f1517716 --- /dev/null +++ b/OpenEdX/LMSDirectoryRouter.swift @@ -0,0 +1,33 @@ +// +// LMSDirectoryRouter.swift +// OpenEdX +// +// Routes the app onward after the learner picks a platform in the LMS Directory +// landing. Registered as `LMSSelectionRouting` so the feature's coordinator (in +// Authorization) can hand control back to the app's navigation without the feature +// depending on the app target. +// + +import UIKit +import SwiftUI +import Core +import Authorization +import Swinject + +final class LMSDirectoryRouter: LMSSelectionRouting { + + func presentDiscovery() { showSignIn() } + + func showLogin() { showSignIn() } + + private func showSignIn() { + guard let navigation = Container.shared.resolve(UINavigationController.self), + let viewModel = Container.shared.resolve( + SignInViewModel.self, + argument: LogistrationSourceScreen.default + ) + else { return } + let controller = UIHostingController(rootView: SignInView(viewModel: viewModel)) + navigation.setViewControllers([controller], animated: true) + } +} diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index f9a377ffe..8501722f5 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -49,7 +49,17 @@ class RouteController: UIViewController { } private func showStartupScreen() { - if let config = Container.shared.resolve(ConfigProtocol.self), config.features.startupScreenEnabled { + let resolvedConfig = Container.shared.resolve(ConfigProtocol.self) + // LMS Directory: before sign-in, let the learner choose which platform to use. + // Flag-gated; when off (default) this branch is skipped and the flow is stock. + if resolvedConfig?.lmsDirectory.enabled == true, + LMSDirectoryFeature.shouldPresentLanding(storage: appStorage) { + let landing = LMSDirectoryFeature.makeLandingController() + navigation.viewControllers = [landing] + present(navigation, animated: false) + return + } + if let config = resolvedConfig, config.features.startupScreenEnabled { let controller = UIHostingController( rootView: StartupView(viewModel: diContainer.resolve(StartupViewModel.self)!)) navigation.viewControllers = [controller] diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index 0763f7467..7cbc446a3 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ CEB1E2702CC14EB000921517 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CEB1E26F2CC14EB000921517 /* OEXFoundation */; }; CEBCA4312CC13CB900076589 /* BranchSDK in Frameworks */ = {isa = PBXBuildFile; productRef = CEBCA4302CC13CB900076589 /* BranchSDK */; }; E8264C634DD8AD314ECE8905 /* Pods_App_Profile_ProfileTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C85ADF87135E03275A980E07 /* Pods_App_Profile_ProfileTests.framework */; }; + ED02897DAD8D8484FD9DFD83 /* ReportLMSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17445EE321A33DCA2F6B12BA /* ReportLMSView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -140,6 +141,7 @@ 02FE9A7F2BC707D400B3C206 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SettingsViewModelTests.swift; path = ProfileTests/Presentation/Settings/SettingsViewModelTests.swift; sourceTree = SOURCE_ROOT; }; 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileBottomSheet.swift; sourceTree = ""; }; 0E5054C44435557666B6D885 /* Pods-App-Profile.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Profile/Pods-App-Profile.debugstage.xcconfig"; sourceTree = ""; }; + 17445EE321A33DCA2F6B12BA /* ReportLMSView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ReportLMSView.swift; path = ../ReportLMSView.swift; sourceTree = ""; }; 3674C51E1BE41D834B5C4E99 /* Pods-App-Profile.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Profile/Pods-App-Profile.debugdev.xcconfig"; sourceTree = ""; }; 52AFEEDE612F39072A418B1B /* Pods-App-Profile-ProfileTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile-ProfileTests.debug.xcconfig"; path = "Target Support Files/Pods-App-Profile-ProfileTests/Pods-App-Profile-ProfileTests.debug.xcconfig"; sourceTree = ""; }; 5788D9F297637D0D4A327E3B /* Pods-App-Profile-ProfileTests.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile-ProfileTests.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Profile-ProfileTests/Pods-App-Profile-ProfileTests.debugprod.xcconfig"; sourceTree = ""; }; @@ -411,6 +413,7 @@ children = ( 02D0FD082AD698380020D752 /* UserProfileView.swift */, 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */, + 17445EE321A33DCA2F6B12BA /* ReportLMSView.swift */, ); path = UserProfile; sourceTree = ""; @@ -718,6 +721,7 @@ 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */, 02F81DDF2BF4D83E002D3604 /* CalendarDialogView.swift in Sources */, 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */, + ED02897DAD8D8484FD9DFD83 /* ReportLMSView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 67a855c71..f12c4956f 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -12,9 +12,10 @@ import Theme import OEXFoundation public struct ProfileView: View { - + @Bindable private var viewModel: ProfileViewModel - + @State private var showReportSheet = false + public init(viewModel: ProfileViewModel) { self.viewModel = viewModel } @@ -80,9 +81,14 @@ public struct ProfileView: View { await viewModel.getMyProfile() } } + .sheet(isPresented: $showReportSheet) { + ReportLMSView(viewModel: ReportLMSViewModel()) { + showReportSheet = false + } + } } } - + private var progressBar: some View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 200) @@ -149,11 +155,28 @@ public struct ProfileView: View { }.padding(.all, 24) profileInfo editProfileButton + if viewModel.config.lmsDirectory.enabled { + reportButton + } Spacer() } } } + // MARK: - Report this LMS (shown only when the LMS Directory feature is on) + private var reportButton: some View { + StyledButton( + NSLocalizedString("Report this LMS", comment: "Profile: report the current platform"), + action: { showReportSheet = true }, + color: Theme.Colors.alert, + textColor: Theme.Colors.white, + borderColor: Theme.Colors.alert + ) + .padding(.horizontal, 24) + .padding(.bottom, 24) + .accessibilityIdentifier("report_lms_button") + } + // MARK: - Profile Info @ViewBuilder private var profileInfo: some View { diff --git a/Profile/Profile/Presentation/Profile/ReportLMSView.swift b/Profile/Profile/Presentation/Profile/ReportLMSView.swift new file mode 100644 index 000000000..4e82efa00 --- /dev/null +++ b/Profile/Profile/Presentation/Profile/ReportLMSView.swift @@ -0,0 +1,379 @@ +// +// ReportLMSView.swift +// Profile +// +// The "Report this LMS" flow shown on the Profile tab when the LMS Directory +// feature is on. A learner can flag the platform they're signed into: pick a +// reason, add a note, optionally attach a screenshot, and send it to the +// registry's moderators (not to the LMS itself). +// + +import SwiftUI +import Core +import Theme +import PhotosUI + +// MARK: - Report this LMS + +/// Moderation reasons a learner can flag the current platform with. Raw values +/// match the registry's categories so the admin console groups them correctly. +public enum LMSReportCategory: String, CaseIterable, Identifiable, Sendable { + case inappropriate + case scam + case impersonation + case spam + case broken + case other + + public var id: String { rawValue } + + var title: String { + switch self { + case .inappropriate: + return NSLocalizedString("Inappropriate or adult content", comment: "LMS report category") + case .scam: + return NSLocalizedString("Scam or phishing", comment: "LMS report category") + case .impersonation: + return NSLocalizedString("Pretends to be someone else", comment: "LMS report category") + case .spam: + return NSLocalizedString("Spam or fake platform", comment: "LMS report category") + case .broken: + return NSLocalizedString("Doesn't work or can't sign in", comment: "LMS report category") + case .other: + return NSLocalizedString("Something else", comment: "LMS report category") + } + } +} + +/// Drives the "Report this LMS" sheet on the Profile tab. Submits a moderation +/// complaint about the platform the learner is currently signed into to the +/// registry (not to the LMS itself); a human moderator decides what to do with it. +@MainActor +public final class ReportLMSViewModel: ObservableObject { + + public enum SubmitState: Equatable { + case editing + case submitting + case success + case failure(String) + } + + let lmsTitle: String + private let baseURL: String? + private let registryURL: String? + + @Published var category: LMSReportCategory = .inappropriate + @Published var message: String = "" + @Published var email: String = "" + @Published private(set) var state: SubmitState = .editing + /// A downscaled preview of the attached screenshot, if any. + @Published private(set) var screenshot: UIImage? + private var screenshotBase64: String? + + public init() { + let base = UserDefaults.standard.string(forKey: "selectedLMSBaseURL") + self.baseURL = (base?.isEmpty ?? true) ? nil : base + self.registryURL = UserDefaults.standard.string(forKey: "lmsRegistryURL") + if let host = base.flatMap({ URL(string: $0)?.host }) { + self.lmsTitle = host + } else { + self.lmsTitle = NSLocalizedString("This platform", comment: "LMS report fallback title") + } + } + + var canSubmit: Bool { + state != .submitting && !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Downscale and JPEG-compress a picked image, keeping a preview + base64. + func attachScreenshot(_ data: Data) { + guard let image = UIImage(data: data) else { return } + let maxDimension: CGFloat = 1280 + let scale = min(1, maxDimension / max(image.size.width, image.size.height)) + let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let resized = UIGraphicsImageRenderer(size: size).image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + guard let jpeg = resized.jpegData(compressionQuality: 0.6) else { return } + screenshot = resized + screenshotBase64 = jpeg.base64EncodedString() + } + + func removeScreenshot() { + screenshot = nil + screenshotBase64 = nil + } + + func submit() { + guard canSubmit else { return } + guard + let baseURL, + let registryURL, + let endpoint = URL(string: registryURL)?.appendingPathComponent("api/v1/reports") + else { + state = .failure( + NSLocalizedString( + "Reporting isn't available for this platform.", + comment: "LMS report unavailable" + ) + ) + return + } + + state = .submitting + let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) + var body: [String: Any] = [ + "base_url": baseURL, + "category": category.rawValue, + "message": message.trimmingCharacters(in: .whitespacesAndNewlines), + "platform": "ios", + "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + ] + if !trimmedEmail.isEmpty { body["reporter_email"] = trimmedEmail } + if let screenshotBase64 { body["screenshot_base64"] = screenshotBase64 } + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + Task { @MainActor in + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + state = .success + } catch { + state = .failure( + NSLocalizedString( + "We couldn't send your report. Check your connection and try again.", + comment: "LMS report failure" + ) + ) + } + } + } +} + +// MARK: - Report this LMS sheet + +/// The "Report this LMS" sheet shown from the Profile tab: pick a reason, add a +/// note, optionally attach a screenshot, and send it to the moderators. +struct ReportLMSView: View { + @ObservedObject var viewModel: ReportLMSViewModel + var onClose: () -> Void + + @State private var photoItem: PhotosPickerItem? + + var body: some View { + NavigationView { + content + .background(Theme.Colors.background.ignoresSafeArea()) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: onClose) { + Text(viewModel.state == .success ? "Done" : "Cancel") + .foregroundColor(Theme.Colors.infoColor) + } + } + } + } + } + + @ViewBuilder + private var content: some View { + if viewModel.state == .success { + successView + } else { + form + } + } + + private var form: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + + section(title: "What's wrong?") { + VStack(spacing: 8) { + ForEach(LMSReportCategory.allCases) { category in + categoryRow(category) + } + } + } + + section(title: "Tell us more") { + ZStack(alignment: .topLeading) { + if viewModel.message.isEmpty { + Text("Describe what happened") + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textSecondary) + .padding(.top, 12) + .padding(.horizontal, 12) + } + TextEditor(text: $viewModel.message) + .frame(minHeight: 110) + .padding(6) + .scrollContentBackground(.hidden) + .foregroundColor(Theme.Colors.textPrimary) + } + .background(Theme.Shapes.textInputShape.fill(Theme.Colors.textInputBackground)) + .overlay(Theme.Shapes.textInputShape.stroke(lineWidth: 1).fill(Theme.Colors.textInputStroke)) + } + + section(title: "Screenshot (optional)") { + if let shot = viewModel.screenshot { + VStack(alignment: .leading, spacing: 8) { + Image(uiImage: shot) + .resizable() + .scaledToFit() + .frame(maxHeight: 180) + .clipShape(RoundedRectangle(cornerRadius: 10)) + Button { + viewModel.removeScreenshot() + photoItem = nil + } label: { + Text("Remove screenshot") + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.alert) + } + } + } else { + PhotosPicker(selection: $photoItem, matching: .images) { + HStack(spacing: 8) { + Image(systemName: "paperclip") + Text("Attach a screenshot") + .font(Theme.Fonts.bodyLarge) + } + .foregroundColor(Theme.Colors.infoColor) + .frame(maxWidth: .infinity) + .padding(14) + .background(Theme.Shapes.textInputShape.fill(Theme.Colors.textInputBackground)) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + } + } + } + + section(title: "Email (optional)") { + TextField("So we can follow up", text: $viewModel.email) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(14) + .background(Theme.Shapes.textInputShape.fill(Theme.Colors.textInputBackground)) + .overlay(Theme.Shapes.textInputShape.stroke(lineWidth: 1).fill(Theme.Colors.textInputStroke)) + } + + if case .failure(let message) = viewModel.state { + Text(message) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.alert) + } + + submitButton + } + .padding(24) + } + .onChange(of: photoItem) { newItem in + guard let newItem else { return } + Task { @MainActor in + if let data = try? await newItem.loadTransferable(type: Data.self) { + viewModel.attachScreenshot(data) + } + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Report a problem") + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) + Text(viewModel.lmsTitle) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textSecondary) + } + } + + private func section(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + content() + } + } + + private func categoryRow(_ category: LMSReportCategory) -> some View { + let selected = viewModel.category == category + return Button { + viewModel.category = category + } label: { + HStack { + Text(category.title) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + Spacer() + Image(systemName: selected ? "largecircle.fill.circle" : "circle") + .foregroundColor(selected ? Theme.Colors.accentColor : Theme.Colors.textSecondary) + } + .padding(14) + .background( + Theme.Shapes.textInputShape + .fill(selected ? Theme.Colors.accentColor.opacity(0.12) : Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(selected ? Theme.Colors.accentColor.opacity(0.5) : Theme.Colors.textInputStroke.opacity(0.4)) + ) + } + .buttonStyle(.plain) + } + + private var submitButton: some View { + Button(action: { viewModel.submit() }) { + HStack(spacing: 10) { + if viewModel.state == .submitting { + ProgressView() + } + Text("Send report") + .font(Theme.Fonts.titleSmall) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + Theme.Shapes.buttonShape + .fill(viewModel.canSubmit ? Theme.Colors.accentColor : Theme.Colors.textSecondary.opacity(0.4)) + ) + } + .disabled(!viewModel.canSubmit) + .buttonStyle(.plain) + } + + private var successView: some View { + VStack(spacing: 16) { + Spacer() + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56)) + .foregroundColor(Theme.Colors.accentColor) + Text("Thanks for the heads-up") + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) + Text("A moderator will look into \(viewModel.lmsTitle) shortly.") + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textSecondary) + .multilineTextAlignment(.center) + Spacer() + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Profile/ProfileTests/Generated/ProfileMocks.generated.swift b/Profile/ProfileTests/Generated/ProfileMocks.generated.swift index 3a6a058c9..8e5983191 100644 --- a/Profile/ProfileTests/Generated/ProfileMocks.generated.swift +++ b/Profile/ProfileTests/Generated/ProfileMocks.generated.swift @@ -18,7 +18,7 @@ import ZipArchive public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { public init() { } - public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { + public init(baseURL: URL = URL(fileURLWithPath: ""), baseSSOURL: URL = URL(fileURLWithPath: ""), ssoFinishedURL: URL = URL(fileURLWithPath: ""), ssoButtonTitle: [String: Any] = [String: Any](), oAuthClientId: String = "", tokenType: TokenType, feedbackEmail: String = "", appStoreLink: String = "", faq: URL? = nil, platformName: String = "", agreement: AgreementConfig, firebase: FirebaseConfig, facebook: FacebookConfig, microsoft: MicrosoftConfig, google: GoogleConfig, appleSignIn: AppleSignInConfig, features: FeaturesConfig, theme: ThemeConfig, uiComponents: UIComponentsConfig, lmsDirectory: LMSDirectoryConfig, discovery: DiscoveryConfig, dashboard: DashboardConfig, braze: BrazeConfig, branch: BranchConfig, program: DiscoveryConfig, experimentalFeatures: ExperimentalFeaturesConfig, URIScheme: String = "") { self.baseURL = baseURL self.baseSSOURL = baseSSOURL self.ssoFinishedURL = ssoFinishedURL @@ -38,6 +38,7 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { self._features = features self._theme = theme self._uiComponents = uiComponents + self._lmsDirectory = lmsDirectory self._discovery = discovery self._dashboard = dashboard self._braze = braze @@ -139,6 +140,12 @@ public final class ConfigProtocolMock: ConfigProtocol, @unchecked Sendable { } + private var _lmsDirectory: LMSDirectoryConfig! + public var lmsDirectory: LMSDirectoryConfig { + get { return _lmsDirectory } + set { _lmsDirectory = newValue } + } + private var _uiComponents: UIComponentsConfig! public var uiComponents: UIComponentsConfig { get { return _uiComponents } @@ -258,7 +265,7 @@ public final class CoreAnalyticsMock: CoreAnalytics { public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public init() { } - public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false) { + public init(accessToken: String? = nil, refreshToken: String? = nil, pushToken: String? = nil, appleSignFullName: String? = nil, appleSignEmail: String? = nil, cookiesDate: Date? = nil, reviewLastShownVersion: String? = nil, lastReviewDate: Date? = nil, user: DataLayer.User? = nil, userSettings: UserSettings? = nil, resetAppSupportDirectoryUserData: Bool? = nil, useRelativeDates: Bool = false, lastUsedSocialAuth: String? = nil, latestAvailableAppVersion: String? = nil, updateAppRequired: Bool = false, selectedLMSBaseURL: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.pushToken = pushToken @@ -274,6 +281,7 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { self.lastUsedSocialAuth = lastUsedSocialAuth self.latestAvailableAppVersion = latestAvailableAppVersion self.updateAppRequired = updateAppRequired + self.selectedLMSBaseURL = selectedLMSBaseURL } @@ -322,6 +330,9 @@ public final class CoreStorageMock: CoreStorage, @unchecked Sendable { public private(set) var updateAppRequiredSetCallCount = 0 public var updateAppRequired: Bool = false { didSet { updateAppRequiredSetCallCount += 1 } } + public private(set) var selectedLMSBaseURLSetCallCount = 0 + public var selectedLMSBaseURL: String? = nil { didSet { selectedLMSBaseURLSetCallCount += 1 } } + private let clearState = MockoloMutex(MockoloHandlerState ()>()) public var clearCallCount: Int { return clearState.withLock(\.callCount) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 48d005148..0e4196341 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -21,3 +21,8 @@ UI_COMPONENTS: LOGIN_REGISTRATION_ENABLED: true SAML_SSO_LOGIN_ENABLED: false SAML_SSO_DEFAULT_LOGIN_BUTTON: false + +LMS_DIRECTORY: + ENABLED: true + DIRECTORY_URL: "https://openedx-lms.stepanok.com" + DIRECTORY_MODE: "" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index e75c30495..fcb807d98 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -22,3 +22,8 @@ UI_COMPONENTS: LOGIN_REGISTRATION_ENABLED: true SAML_SSO_LOGIN_ENABLED: false SAML_SSO_DEFAULT_LOGIN_BUTTON: false + +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_MODE: "" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 117a7f49e..b3af40756 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -21,3 +21,8 @@ UI_COMPONENTS: LOGIN_REGISTRATION_ENABLED: true SAML_SSO_LOGIN_ENABLED: false SAML_SSO_DEFAULT_LOGIN_BUTTON: false + +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_MODE: "" From fb69d146969b1d881b6b7e13a4ab535c0e3c0638 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:03:56 +0300 Subject: [PATCH 21/24] fix: don't gate LMS directory on the app's stock-host reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory talks to the site registry, not the app's API host. It was being blocked by the global connectivity check (which pings API_HOST_URL) and by a ViewModel override that forced an offline state from the same signal — so in dev, where API_HOST_URL is an unreachable placeholder, the catalog never loaded even though the registry was reachable. Reachability is now decided by the actual registry call: a genuine URLError maps to .offline, an unreachable stock host does not. Removed the connectivity dependency from the directory service and view model. --- .../TenantPicker/LMSDirectoryFeature.swift | 10 ++--- .../TenantPicker/LMSDirectoryViewModel.swift | 19 +--------- .../RemoteLMSDirectoryService.swift | 38 +++++++++++-------- 3 files changed, 26 insertions(+), 41 deletions(-) diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift index 54a5f8969..82c229cb2 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift @@ -81,8 +81,7 @@ public enum LMSDirectoryFeature { historyStore: container.resolve(LMSHistoryStoreProtocol.self)!, coordinator: coordinator, overridesStore: container.resolve(LMSOverridesStoreProtocol.self)!, - analytics: container.resolve(LMSDirectoryAnalytics.self)!, - connectivity: container.resolve(ConnectivityProtocol.self)! + analytics: container.resolve(LMSDirectoryAnalytics.self)! ) } @@ -102,13 +101,10 @@ public enum LMSDirectoryFeature { }.inObjectScope(.container) container.register(LMSDirectoryService.self) { resolver in - let connectivity = resolver.resolve(ConnectivityProtocol.self)! if let baseURL = directoryURL { - return RemoteLMSDirectoryService( - baseURL: baseURL, - connectivity: connectivity - ) + return RemoteLMSDirectoryService(baseURL: baseURL) } + let connectivity = resolver.resolve(ConnectivityProtocol.self)! return MockLMSDirectoryService(connectivity: connectivity) }.inObjectScope(.container) } diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift index d592a0661..382c0cfc3 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift @@ -32,28 +32,23 @@ final class LMSDirectoryViewModel: ObservableObject { private let coordinator: LMSSelectionCoordinating private let overridesStore: LMSOverridesStoreProtocol private let analytics: LMSDirectoryAnalytics - private let connectivity: ConnectivityProtocol private let historyLimit = 10 private var debounceTask: Task? private var historyTask: Task? - private var cancellables: Set = [] init( service: LMSDirectoryService, historyStore: LMSHistoryStoreProtocol, coordinator: LMSSelectionCoordinating, overridesStore: LMSOverridesStoreProtocol, - analytics: LMSDirectoryAnalytics, - connectivity: ConnectivityProtocol + analytics: LMSDirectoryAnalytics ) { self.service = service self.historyStore = historyStore self.coordinator = coordinator self.overridesStore = overridesStore self.analytics = analytics - self.connectivity = connectivity - observeConnectivity() loadHistory() applyPersistedTheme() loadConfig() @@ -130,18 +125,6 @@ final class LMSDirectoryViewModel: ObservableObject { } } - private func observeConnectivity() { - connectivity.internetReachableSubject - .receive(on: DispatchQueue.main) - .sink { [weak self] state in - guard let state, let self else { return } - if case .notReachable = state, !self.searchText.isEmpty { - self.state = .offline - } - } - .store(in: &cancellables) - } - private func scheduleSearch() { debounceTask?.cancel() guard !searchText.isEmpty else { diff --git a/Authorization/Authorization/Presentation/TenantPicker/RemoteLMSDirectoryService.swift b/Authorization/Authorization/Presentation/TenantPicker/RemoteLMSDirectoryService.swift index 8f3f1aa2c..a0077b4b1 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/RemoteLMSDirectoryService.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/RemoteLMSDirectoryService.swift @@ -5,7 +5,6 @@ final class RemoteLMSDirectoryService: LMSDirectoryService { private let baseURL: URL private let session: URLSession - private let connectivity: ConnectivityProtocol private let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -14,17 +13,13 @@ final class RemoteLMSDirectoryService: LMSDirectoryService { init( baseURL: URL, - connectivity: ConnectivityProtocol, session: URLSession = .shared ) { self.baseURL = baseURL - self.connectivity = connectivity self.session = session } func search(query: String) async throws -> [LMSSearchResult] { - try await ensureOnline() - var components = URLComponents( url: baseURL.appendingPathComponent("/api/v1/directory"), resolvingAgainstBaseURL: false @@ -33,7 +28,7 @@ final class RemoteLMSDirectoryService: LMSDirectoryService { components.queryItems = [URLQueryItem(name: "q", value: query)] } - let (data, response) = try await session.data(from: components.url!) + let (data, response) = try await data(from: components.url!) try validateResponse(response) do { @@ -54,10 +49,8 @@ final class RemoteLMSDirectoryService: LMSDirectoryService { } func fetchDetails(id: String) async throws -> LMSDetail { - try await ensureOnline() - let url = baseURL.appendingPathComponent("/api/v1/directory/\(id)") - let (data, response) = try await session.data(from: url) + let (data, response) = try await data(from: url) guard let httpResponse = response as? HTTPURLResponse else { throw LMSDirectoryError.decodingFailed @@ -78,9 +71,8 @@ final class RemoteLMSDirectoryService: LMSDirectoryService { } func fetchConfig() async throws -> LMSRegistryConfig { - try await ensureOnline() let url = baseURL.appendingPathComponent("/api/v1/config") - let (data, response) = try await session.data(from: url) + let (data, response) = try await data(from: url) try validateResponse(response) do { return try decoder.decode(LMSConfigDTO.self, from: data).domainModel @@ -90,14 +82,13 @@ final class RemoteLMSDirectoryService: LMSDirectoryService { } func fetchFeatured() async throws -> [LMSSearchResult] { - try await ensureOnline() var components = URLComponents( url: baseURL.appendingPathComponent("/api/v1/directory"), resolvingAgainstBaseURL: false )! components.queryItems = [URLQueryItem(name: "featured", value: "true")] - let (data, response) = try await session.data(from: components.url!) + let (data, response) = try await data(from: components.url!) try validateResponse(response) do { let listResponse = try decoder.decode(LMSListResponse.self, from: data) @@ -118,13 +109,28 @@ final class RemoteLMSDirectoryService: LMSDirectoryService { // MARK: - Private - private func ensureOnline() async throws { - let isOnline = await MainActor.run { connectivity.isInternetAvaliable } - guard isOnline else { + /// Fetch from the registry. Reachability is decided by this call itself — a + /// genuinely offline device surfaces as `.offline`. The directory must not be + /// gated by reachability to the app's stock host, which isn't the registry and + /// (in the LMS Directory flow) isn't even chosen yet. + private func data(from url: URL) async throws -> (Data, URLResponse) { + do { + return try await session.data(from: url) + } catch let error as URLError where Self.offlineCodes.contains(error.code) { throw LMSDirectoryError.offline } } + private static let offlineCodes: Set = [ + .notConnectedToInternet, + .networkConnectionLost, + .cannotConnectToHost, + .cannotFindHost, + .dnsLookupFailed, + .timedOut, + .dataNotAllowed + ] + private func validateResponse(_ response: URLResponse) throws { guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { From 9ffc8cc0bafdd0b9b098c5d5975061e76502f487 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:13:50 +0300 Subject: [PATCH 22/24] test: cover LMS directory offline handling; fix curated offline masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LMSDirectoryViewModel.loadFeatured now maps LMSDirectoryError.offline to the .offline state (was swallowed into a generic .error), matching search. - Add AuthorizationTests/…/LMSDirectory regression tests: * RemoteLMSDirectoryService decodes registry items and maps real URLError (notConnected / timedOut / cannotConnectToHost) to .offline — proving the directory no longer depends on stock-host ConnectivityProtocol. * LMSDirectoryViewModel constructs without any connectivity dependency and surfaces .offline for both search and curated/featured loading. --- .../Authorization.xcodeproj/project.pbxproj | 16 +++ .../TenantPicker/LMSDirectoryViewModel.swift | 4 + .../LMSDirectoryViewModelTests.swift | 123 ++++++++++++++++++ .../RemoteLMSDirectoryServiceTests.swift | 116 +++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift create mode 100644 Authorization/AuthorizationTests/Presentation/LMSDirectory/RemoteLMSDirectoryServiceTests.swift diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index 438144183..349a66225 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -33,6 +33,8 @@ 5FB79D2802949372CDAF08D6 /* Pods_App_Authorization_AuthorizationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4FAE9B7FD61FF88C9C4FE1E8 /* Pods_App_Authorization_AuthorizationTests.framework */; }; 675A3266DB7422CBE5D8C383 /* LMSDirectoryQRScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E65F49C6D33DA13103C3592 /* LMSDirectoryQRScannerView.swift */; }; 6D7A416158E2FDD2F33B92AE /* LMSHistoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E53AD905F2FFC3F8EBB47210 /* LMSHistoryStore.swift */; }; + 768AF63A56D5051A729CA064 /* RemoteLMSDirectoryServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C762F79B426714E73FF19E46 /* RemoteLMSDirectoryServiceTests.swift */; }; + 935FEA69BE7D57F57D42BB86 /* LMSDirectoryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9EF18C82B8CA9A89481E82 /* LMSDirectoryViewModelTests.swift */; }; 95F57194827709F98991ECF9 /* LMSThemeApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0922F56B70DDA9F2910C6C /* LMSThemeApplier.swift */; }; 999B1445AA69C9D434DA39CF /* RemoteLMSDirectoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52219A516317365C162B9670 /* RemoteLMSDirectoryService.swift */; }; 99C1654B2C0C4F0600DC384D /* ContainerWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654A2C0C4F0600DC384D /* ContainerWebView.swift */; }; @@ -132,6 +134,7 @@ 99C1654C2C0C4F2F00DC384D /* SSOHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOHelper.swift; sourceTree = ""; }; 99C1654E2C0C4F5900DC384D /* SSOWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOWebView.swift; sourceTree = ""; }; 99C165502C0C4F7B00DC384D /* SSOWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOWebViewModel.swift; sourceTree = ""; }; + 9B9EF18C82B8CA9A89481E82 /* LMSDirectoryViewModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryViewModelTests.swift; path = ../LMSDirectory/LMSDirectoryViewModelTests.swift; sourceTree = ""; }; 9BF6A1004A955E24527FCF0F /* Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; sourceTree = ""; }; A5B4680F2F29C845002A4ECA /* AuthorizationMocks.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationMocks.generated.swift; sourceTree = ""; }; A99D45203C981893C104053A /* Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; sourceTree = ""; }; @@ -141,6 +144,7 @@ BADB3F542AD6DFC3004D5CFA /* SocialAuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthViewModel.swift; sourceTree = ""; }; BC0922F56B70DDA9F2910C6C /* LMSThemeApplier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSThemeApplier.swift; path = Authorization/Presentation/TenantPicker/LMSThemeApplier.swift; sourceTree = ""; }; C54635D2863A11AFCC9A5235 /* LMSDirectoryFeature.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryFeature.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift; sourceTree = ""; }; + C762F79B426714E73FF19E46 /* RemoteLMSDirectoryServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteLMSDirectoryServiceTests.swift; path = ../LMSDirectory/RemoteLMSDirectoryServiceTests.swift; sourceTree = ""; }; CEB259FA2CC13A36007FC792 /* SocialAuthError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; CEB259FC2CC13A36007FC792 /* AppleAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAuthProvider.swift; sourceTree = ""; }; CEB259FD2CC13A36007FC792 /* GoogleAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuthProvider.swift; sourceTree = ""; }; @@ -266,6 +270,7 @@ children = ( 02E0618329DC2373006E9024 /* ResetPasswordViewModelTests.swift */, 07169463296D96DD00E3DED6 /* SignInViewModelTests.swift */, + D81BDDA5554F6699B56037FD /* LMSDirectory */, ); path = Login; sourceTree = ""; @@ -418,6 +423,15 @@ path = SocialAuth; sourceTree = ""; }; + D81BDDA5554F6699B56037FD /* LMSDirectory */ = { + isa = PBXGroup; + children = ( + C762F79B426714E73FF19E46 /* RemoteLMSDirectoryServiceTests.swift */, + 9B9EF18C82B8CA9A89481E82 /* LMSDirectoryViewModelTests.swift */, + ); + name = LMSDirectory; + sourceTree = ""; + }; E03261622AE6464A002CA7EB /* Startup */ = { isa = PBXGroup; children = ( @@ -618,6 +632,8 @@ 07169464296D96DD00E3DED6 /* SignInViewModelTests.swift in Sources */, A5B468112F29C845002A4ECA /* AuthorizationMocks.generated.swift in Sources */, 02E0618429DC2373006E9024 /* ResetPasswordViewModelTests.swift in Sources */, + 768AF63A56D5051A729CA064 /* RemoteLMSDirectoryServiceTests.swift in Sources */, + 935FEA69BE7D57F57D42BB86 /* LMSDirectoryViewModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift index 382c0cfc3..d9bbd3805 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift @@ -106,6 +106,10 @@ final class LMSDirectoryViewModel: ObservableObject { self.results = items self.state = items.isEmpty ? .empty : .results } + } catch LMSDirectoryError.offline { + await MainActor.run { + self.state = .offline + } } catch { await MainActor.run { self.state = .error("We couldn't load the list of platforms.") diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift new file mode 100644 index 000000000..d0a5740e3 --- /dev/null +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift @@ -0,0 +1,123 @@ +// +// LMSDirectoryViewModelTests.swift +// AuthorizationTests +// +// Regression coverage for the LMS Directory connectivity fix. The view model no +// longer takes a ConnectivityProtocol (this file wouldn't compile if it did), and +// a real registry `.offline` failure surfaces as `.offline` for both search and +// curated/featured loading — not a generic error. +// + +import XCTest +import Foundation +@testable import Core +@testable import Authorization + +@MainActor +final class LMSDirectoryViewModelTests: XCTestCase { + + func test_search_success_setsResultsState() async { + let service = StubDirectoryService() + service.searchResult = .success([Self.sampleResult]) + let viewModel = makeViewModel(service: service) + + viewModel.searchText = "aten" + + await waitUntil { viewModel.state == .results } + XCTAssertEqual(viewModel.results.count, 1) + XCTAssertEqual(viewModel.results.first?.id, "5") + } + + func test_search_offlineFailure_setsOfflineState() async { + let service = StubDirectoryService() + service.searchResult = .failure(LMSDirectoryError.offline) + let viewModel = makeViewModel(service: service) + + viewModel.searchText = "aten" + + await waitUntil { viewModel.state == .offline } + XCTAssertEqual(viewModel.state, .offline) + } + + // Covers the curated/provider path: featured loading must map .offline to + // .offline too, not to a generic .error. + func test_curatedFeatured_offlineFailure_setsOfflineState() async { + let service = StubDirectoryService() + service.config = LMSRegistryConfig( + directoryMode: "curated", + providerName: "Provider", + providerTagline: "" + ) + service.featuredResult = .failure(LMSDirectoryError.offline) + + let viewModel = makeViewModel(service: service) + + await waitUntil { viewModel.state == .offline } + XCTAssertEqual(viewModel.state, .offline) + } + + // MARK: - Helpers + + private func makeViewModel(service: LMSDirectoryService) -> LMSDirectoryViewModel { + LMSDirectoryViewModel( + service: service, + historyStore: StubHistoryStore(), + coordinator: StubCoordinator(), + overridesStore: StubOverridesStore(), + analytics: LMSDirectoryAnalyticsNoop() + ) + } + + private func waitUntil( + timeout: TimeInterval = 3, + _ condition: @MainActor () -> Bool + ) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + try? await Task.sleep(nanoseconds: 50_000_000) + } + } + + private static var sampleResult: LMSSearchResult { + LMSSearchResult( + id: "5", + title: "Atentamente", + shortDescription: "Atentamente MX", + baseURL: URL(string: "https://educar.atentamente.mx")!, + logoURL: nil, + accentColorHex: "#f15d49" + ) + } +} + +// MARK: - Test doubles + +private final class StubDirectoryService: LMSDirectoryService, @unchecked Sendable { + var searchResult: Result<[LMSSearchResult], Error> = .success([]) + var featuredResult: Result<[LMSSearchResult], Error> = .success([]) + var config: LMSRegistryConfig = .searchDefault + + func search(query: String) async throws -> [LMSSearchResult] { try searchResult.get() } + func fetchDetails(id: String) async throws -> LMSDetail { throw LMSDirectoryError.notFound } + func fetchConfig() async throws -> LMSRegistryConfig { config } + func fetchFeatured() async throws -> [LMSSearchResult] { try featuredResult.get() } +} + +private final class StubHistoryStore: LMSHistoryStoreProtocol, @unchecked Sendable { + func fetchHistory(limit: Int) -> [LMSHistoryItem] { [] } + func save(detail: LMSDetail, payload: Data, pinned: Bool) throws {} + func clearHistory() throws {} + func deleteAllOverrides() throws {} + func unpinAll() throws {} + func pinnedItem() -> LMSHistoryItem? { nil } +} + +private final class StubCoordinator: LMSSelectionCoordinating, @unchecked Sendable { + func applySelection(detail: LMSDetail, payload: Data, fromHistory: Bool) async {} +} + +private final class StubOverridesStore: LMSOverridesStoreProtocol, @unchecked Sendable { + func save(detail: LMSDetail, payload: Data, storage: CoreStorage?) throws {} + func currentSelection() -> LMSDetail? { nil } + func clear(storage: CoreStorage?) throws {} +} diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/RemoteLMSDirectoryServiceTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/RemoteLMSDirectoryServiceTests.swift new file mode 100644 index 000000000..fb5b05167 --- /dev/null +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/RemoteLMSDirectoryServiceTests.swift @@ -0,0 +1,116 @@ +// +// RemoteLMSDirectoryServiceTests.swift +// AuthorizationTests +// +// Regression coverage for the LMS Directory connectivity fix: the directory +// service must talk to the registry directly (no ConnectivityProtocol / stock-host +// ping) and map a genuine network failure to `.offline`. +// + +import XCTest +@testable import Authorization + +final class RemoteLMSDirectoryServiceTests: XCTestCase { + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + return URLSession(configuration: configuration) + } + + private func makeService() -> RemoteLMSDirectoryService { + // Note: the initializer takes no ConnectivityProtocol — the directory's + // reachability is decided by the call itself, not by the app's stock host. + RemoteLMSDirectoryService( + baseURL: URL(string: "https://registry.test")!, + session: makeSession() + ) + } + + override func tearDown() { + StubURLProtocol.handler = nil + super.tearDown() + } + + func test_search_decodesRegistryItems() async throws { + let json = Data( + """ + {"items":[{"id":"5","title":"Atentamente","short_description":"Atentamente MX", + "base_url":"https://educar.atentamente.mx","logo_url":"https://cdn.example.com/a.png", + "accent_color":"#f15d49"}]} + """.utf8 + ) + StubURLProtocol.handler = { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (response, json) + } + + let results = try await makeService().search(query: "aten") + + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.id, "5") + XCTAssertEqual(results.first?.title, "Atentamente") + XCTAssertEqual(results.first?.baseURL.absoluteString, "https://educar.atentamente.mx") + XCTAssertEqual(results.first?.accentColorHex, "#f15d49") + } + + func test_search_mapsNoConnectionToOffline() async { + StubURLProtocol.handler = { _ in throw URLError(.notConnectedToInternet) } + await assertOffline { try await self.makeService().search(query: "x") } + } + + func test_search_mapsTimeoutToOffline() async { + StubURLProtocol.handler = { _ in throw URLError(.timedOut) } + await assertOffline { try await self.makeService().search(query: "x") } + } + + func test_fetchFeatured_mapsCannotConnectToOffline() async { + StubURLProtocol.handler = { _ in throw URLError(.cannotConnectToHost) } + await assertOffline { try await self.makeService().fetchFeatured() } + } + + private func assertOffline( + _ operation: @escaping () async throws -> [LMSSearchResult] + ) async { + do { + _ = try await operation() + XCTFail("Expected LMSDirectoryError.offline") + } catch let error as LMSDirectoryError { + guard case .offline = error else { + return XCTFail("Expected .offline, got \(error)") + } + } catch { + XCTFail("Expected LMSDirectoryError.offline, got \(error)") + } + } +} + +/// Injects canned responses / errors into a `URLSession` for deterministic tests. +final class StubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} From bdfd832f86e1b9f85df9d74726e421e6b959bc26 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:28:43 +0300 Subject: [PATCH 23/24] feat: brand sign-in for the selected LMS (logo, OAuth client, change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the per-LMS experience so a selected platform actually works and looks like itself: - Config.oAuthClientId / feedbackEmail honor the per-LMS values persisted at selection (was the cause of failed logins — the app sent the empty stock client id instead of the platform's registered mobile client). - SignInView shows the selected LMS logo (falls back to the app logo) and a 'Selected LMS / Change' banner returning to the directory landing. - LMSSelectionRouting.showLanding() + LMSDirectoryRouter implementation. - CoreTests: overrides apply when enabled+selected, ignored when the flag is off, and fall back to config when nothing is selected. --- .../Presentation/Login/SignInView.swift | 62 ++++++++++++++++- .../LMSSelectionCoordinator.swift | 2 + Core/Core.xcodeproj/project.pbxproj | 4 ++ Core/Core/Configuration/Config/Config.swift | 12 ++++ .../ConfigLMSDirectoryTests.swift | 68 +++++++++++++++++++ OpenEdX/LMSDirectoryRouter.swift | 6 ++ 6 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index aeb19f3e4..dde1ed93d 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -49,9 +49,7 @@ public struct SignInView: View { } VStack(alignment: .center) { - ThemeAssets.appLogo.swiftUIImage - .resizable() - .aspectRatio(contentMode: .fit) + lmsLogoView .frame(maxWidth: 189, maxHeight: 89) .padding(.top, isHorizontal ? 20 : 40) .padding(.bottom, isHorizontal ? 10 : 40) @@ -61,6 +59,7 @@ public struct SignInView: View { ScrollView { VStack { VStack(alignment: .leading) { + selectedLMSBanner if viewModel.config.uiComponents.loginRegistrationEnabled { Text(AuthLocalization.SignIn.logInTitle) .font(Theme.Fonts.displaySmall) @@ -320,6 +319,63 @@ public struct SignInView: View { viewModel.router.showWebBrowser(title: "", url: url) return .handled } + + // MARK: - LMS Directory branding + + /// The platform the learner picked, when the feature is on. Drives the logo and + /// the "Change" banner so sign-in is branded for the selected LMS. + private var lmsSelection: LMSDirectorySelectionInfo? { + guard viewModel.config.lmsDirectory.enabled else { return nil } + return LMSDirectoryFeature.currentSelectionInfo() + } + + @ViewBuilder + private var lmsLogoView: some View { + if let logoURL = lmsSelection?.logoURL { + AsyncImage(url: logoURL) { image in + image.resizable().aspectRatio(contentMode: .fit) + } placeholder: { + ThemeAssets.appLogo.swiftUIImage.resizable().aspectRatio(contentMode: .fit) + } + } else { + ThemeAssets.appLogo.swiftUIImage.resizable().aspectRatio(contentMode: .fit) + } + } + + @ViewBuilder + private var selectedLMSBanner: some View { + if let info = lmsSelection { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(NSLocalizedString("Selected LMS", comment: "SignIn: selected platform label")) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textSecondary) + Text(info.title) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + .lineLimit(1) + .accessibilityIdentifier("selected_lms_title") + } + Spacer() + Button(NSLocalizedString("Change", comment: "SignIn: change selected platform")) { + Container.shared.resolve(LMSSelectionRouting.self)?.showLanding() + } + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + .accessibilityIdentifier("change_lms_button") + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Theme.Shapes.textInputShape.fill(Theme.Colors.textInputBackground)) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke.opacity(0.5)) + ) + .padding(.bottom, 16) + .accessibilityIdentifier("selected_lms_banner") + } + } } #if DEBUG diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift index 6f008d528..c1a04dd63 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift @@ -8,6 +8,8 @@ import Alamofire public protocol LMSSelectionRouting: AnyObject { func presentDiscovery() func showLogin() + /// Return to the LMS directory landing (the sign-in "Change" affordance). + func showLanding() } @MainActor diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 699a23327..c418e1cac 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */; }; 14769D3C2B9822EE00AB36D4 /* CoreAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */; }; + 3AA0380C24561FC1FC4E42AA /* ConfigLMSDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05340946BE268A7D4BE1FD69 /* ConfigLMSDirectoryTests.swift */; }; 5E58740A2AA9DF20F4644191 /* Pods_App_Core_CoreTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33FA09A20AAE2B2A0BA89190 /* Pods_App_Core_CoreTests.framework */; }; 9784D47E2BF7762800AFEFFF /* FullScreenErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; @@ -290,6 +291,7 @@ 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCellView.swift; sourceTree = ""; }; 02F6EF4928D9F0A700835477 /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = ""; }; 043DD0B526F919DFA1C5E600 /* Pods-App-Core-CoreTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releaseprod.xcconfig"; sourceTree = ""; }; + 05340946BE268A7D4BE1FD69 /* ConfigLMSDirectoryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigLMSDirectoryTests.swift; sourceTree = ""; }; 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIComponentsConfig.swift; sourceTree = ""; }; 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DragAndDropCssInjection.swift; sourceTree = ""; }; 0649878B2B4D69FE0071642A /* WebviewInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebviewInjection.swift; sourceTree = ""; }; @@ -925,6 +927,7 @@ children = ( E09179FC2B0F204D002AB695 /* ConfigTests.swift */, BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */, + 05340946BE268A7D4BE1FD69 /* ConfigLMSDirectoryTests.swift */, ); path = Configuration; sourceTree = ""; @@ -1157,6 +1160,7 @@ CE953A3B2CD0DA940023D667 /* CoreMocks.generated.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */, + 3AA0380C24561FC1FC4E42AA /* ConfigLMSDirectoryTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index a424c9e40..3436d0d0d 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -159,6 +159,13 @@ extension Config: ConfigProtocol { } public var oAuthClientId: String { + // LMS Directory: sign in against the selected platform's own registered + // mobile OAuth client, persisted at selection time. Off (default) → config. + if lmsDirectory.enabled, + let override = UserDefaults.standard.string(forKey: "lmsDirectory.selected_oauth_client_id"), + !override.isEmpty { + return override + } guard let clientID = string(for: ConfigKeys.oAuthClientID.rawValue) else { fatalError("Unable to find OAuth ClientID in config.") } @@ -173,6 +180,11 @@ extension Config: ConfigProtocol { } public var feedbackEmail: String { + if lmsDirectory.enabled, + let override = UserDefaults.standard.string(forKey: "lmsDirectory.selected_feedback_email"), + !override.isEmpty { + return override + } return string(for: ConfigKeys.feedbackEmailAddress.rawValue) ?? "" } diff --git a/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift b/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift new file mode 100644 index 000000000..55e1250bb --- /dev/null +++ b/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift @@ -0,0 +1,68 @@ +// +// ConfigLMSDirectoryTests.swift +// CoreTests +// +// Regression coverage for the LMS Directory per-platform config overrides. When +// the feature is on and a platform is selected, the app must talk to that LMS and +// sign in with *its* OAuth client id / feedback email — not the baked-in config. +// When the flag is off (default) the stock config values always win. +// + +import XCTest +@testable import Core + +final class ConfigLMSDirectoryTests: XCTestCase { + + private let baseURLKey = "selectedLMSBaseURL" + private let clientIdKey = "lmsDirectory.selected_oauth_client_id" + private let feedbackKey = "lmsDirectory.selected_feedback_email" + + override func tearDown() { + [baseURLKey, clientIdKey, feedbackKey].forEach { + UserDefaults.standard.removeObject(forKey: $0) + } + super.tearDown() + } + + private func makeConfig(enabled: Bool) -> Config { + Config(properties: [ + "API_HOST_URL": "https://config-host.example.com", + "OAUTH_CLIENT_ID": "config_client", + "FEEDBACK_EMAIL_ADDRESS": "config@example.com", + "TOKEN_TYPE": "JWT", + "LMS_DIRECTORY": ["ENABLED": enabled] + ]) + } + + func test_selectedLMSOverridesApplied_whenEnabled() { + UserDefaults.standard.set("https://picked-lms.example.com", forKey: baseURLKey) + UserDefaults.standard.set("picked_client", forKey: clientIdKey) + UserDefaults.standard.set("picked@example.com", forKey: feedbackKey) + + let config = makeConfig(enabled: true) + + XCTAssertEqual(config.baseURL.absoluteString, "https://picked-lms.example.com") + XCTAssertEqual(config.oAuthClientId, "picked_client") + XCTAssertEqual(config.feedbackEmail, "picked@example.com") + } + + func test_overridesIgnored_whenFeatureDisabled() { + UserDefaults.standard.set("https://picked-lms.example.com", forKey: baseURLKey) + UserDefaults.standard.set("picked_client", forKey: clientIdKey) + UserDefaults.standard.set("picked@example.com", forKey: feedbackKey) + + let config = makeConfig(enabled: false) + + XCTAssertEqual(config.baseURL.absoluteString, "https://config-host.example.com") + XCTAssertEqual(config.oAuthClientId, "config_client") + XCTAssertEqual(config.feedbackEmail, "config@example.com") + } + + func test_fallsBackToConfig_whenEnabledButNothingSelected() { + let config = makeConfig(enabled: true) + + XCTAssertEqual(config.baseURL.absoluteString, "https://config-host.example.com") + XCTAssertEqual(config.oAuthClientId, "config_client") + XCTAssertEqual(config.feedbackEmail, "config@example.com") + } +} diff --git a/OpenEdX/LMSDirectoryRouter.swift b/OpenEdX/LMSDirectoryRouter.swift index 5f1517716..d9e9eebd0 100644 --- a/OpenEdX/LMSDirectoryRouter.swift +++ b/OpenEdX/LMSDirectoryRouter.swift @@ -20,6 +20,12 @@ final class LMSDirectoryRouter: LMSSelectionRouting { func showLogin() { showSignIn() } + func showLanding() { + guard let navigation = Container.shared.resolve(UINavigationController.self) else { return } + let landing = LMSDirectoryFeature.makeLandingController() + navigation.setViewControllers([landing], animated: true) + } + private func showSignIn() { guard let navigation = Container.shared.resolve(UINavigationController.self), let viewModel = Container.shared.resolve( From 2e0b8fe535e8a40d02205c12f49c790d3d4f4ed1 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:11:52 +0300 Subject: [PATCH 24/24] fix: theme fixes --- AUDIT-20260706-2239.md | 69 +++++++++++ .../Authorization.xcodeproj/project.pbxproj | 4 - .../Presentation/Login/SignInView.swift | 9 +- .../Registration/SignUpView.swift | 3 +- .../Reset Password/ResetPasswordView.swift | 3 +- .../TenantPicker/LMSDirectoryFeature.swift | 39 +++++- .../LMSDirectoryLandingIntroView.swift | 8 +- .../LMSDirectoryLandingView.swift | 22 ++-- .../LMSDirectoryQRInstructionsView.swift | 43 ------- .../LMSDirectoryQRScannerView.swift | 82 +++++++++++-- .../TenantPicker/LMSDirectoryView.swift | 17 ++- .../TenantPicker/LMSDirectoryViewModel.swift | 75 ++++++++++-- .../TenantPicker/LMSHistoryStore.swift | 1 + .../TenantPicker/LMSThemeApplier.swift | 40 +++++- .../LMSDirectoryViewModelTests.swift | 115 +++++++++++++++--- Core/Core/Configuration/Config/Config.swift | 14 ++- Core/Core/Configuration/Connectivity.swift | 41 ++++--- .../View/Base/VideoDownloadQualityView.swift | 3 +- .../ConfigLMSDirectoryTests.swift | 20 ++- OpenEdX.xcodeproj/project.pbxproj | 4 +- OpenEdX/AppDelegate.swift | 7 +- OpenEdX/LMSDirectoryRouter.swift | 6 +- OpenEdX/RouteController.swift | 2 +- OpenEdX/Router.swift | 19 ++- .../DatesAndCalendar/CoursesToSyncView.swift | 3 +- .../DatesAndCalendarView.swift | 3 +- .../SyncCalendarOptionsView.swift | 3 +- .../Presentation/Profile/ProfileView.swift | 5 +- .../Settings/ManageAccountView.swift | 3 +- .../Presentation/Settings/SettingsView.swift | 3 +- .../Settings/VideoQualityView.swift | 3 +- .../Settings/VideoSettingsView.swift | 3 +- Theme/Theme/Theme.swift | 35 +++++- 33 files changed, 542 insertions(+), 165 deletions(-) create mode 100644 AUDIT-20260706-2239.md delete mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift diff --git a/AUDIT-20260706-2239.md b/AUDIT-20260706-2239.md new file mode 100644 index 000000000..1ae53aa0f --- /dev/null +++ b/AUDIT-20260706-2239.md @@ -0,0 +1,69 @@ +**1. Резюме** + +Це не “красиве демо”, а великий реальний iOS-клієнт Open edX з живими API, кешем, курсами, профілем, дискусіями, прогресом, датами й downloads. +Але як готовий сервіс “під ключ” він зараз не доведений: дефолтний prod/stage конфіг веде на `localhost`, OAuth порожній, частина фіч вимкнена. +Найбільший розрив: проєкт залежить від конкретної Open edX інстанції та mobile API після грудня 2023, а в репозиторії немає доказу, що поточний build приймально пройдений на реальному LMS. +Multi-tenant/LMS Directory виглядає як нова надбудова, але runtime-override прапорці зберігаються і не всюди реально застосовуються. +Вердикт: сильна база для production app, але перед оцінювачами це ризикує виглядати як “майже готово”, якщо не закрити конфігурацію, інтеграційні докази й tenant edge cases. + +**2. Інтернет-Контекст** + +Публічний репозиторій визначає продукт як “modern vision” iOS-застосунку для Open edX від Raccoon Gang, а не окрему тендерну систему; README прямо вимагає налаштувати `config_settings.yaml` і `config.yaml` під свою Open edX конфігурацію. Там же сказано, що переклади не включені й мають підтягуватись окремо перед тестуванням/публікацією. + +Проєкт таргетить актуальний Open edX release і mobile APIs; для платформ старіших за грудень 2023 потрібен API plugin. Це збігається з FC-0031: мета була підключити зміни edx-platform API для нових мобільних застосунків, які спирались на API-зміни, спершу розроблені Raccoon Gang. Plugin `mobile-api-extensions` прямо описаний як extended Open edX APIs for mobile applications. + +Raccoon Gang є офіційним Open edX provider; їхній профіль виділяє custom LMS, mobile learning apps, gamification, e-commerce, theming, third-party auth, hosting/migration/support. Тобто оцінювачі очікуватимуть не просто екрани, а інтеграцію з реальним Open edX стеком. + +Є Android-аналог з тією ж логікою: налаштувати config під Open edX, підтягнути translations, використовувати актуальні mobile APIs або plugin для старих платформ. iOS repo має latest release `v2.2(Ulmo.1)` від 23 жовтня 2025, тобто орієнтир очікувань зараз близький до сучасного Open edX mobile, а не legacy app. + +**3. Критичні Розриви** + +| Вимога / очікування | Що є насправді | Що треба | +|---|---|---| +| App має підключатись до реального Open edX LMS | `prod/config.yaml` має `API_HOST_URL: http://localhost:8000`, empty `OAUTH_CLIENT_ID`, LMS Directory off: [config.yaml](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/default_config/prod/config.yaml:1) | Зафіксувати приймальний config для конкретного LMS: host, OAuth client, SSO, privacy/TOS, Firebase, app store id | +| Сумісність з mobile APIs | Код реально ходить у `/api/mobile/v4`, course progress, dates, discussions, enrollment: [CourseEndpoint.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/Course/Course/Data/Network/CourseEndpoint.swift:86) | Матриця “LMS release → supported / plugin needed”, smoke test на Redwood/Ulmo або клієнтській інстанції | +| Переклади перед публікацією | README каже, що translations не включені: [README.md](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/README.md:22) | Перед демо/прийомкою виконати translation pipeline або чесно обмежити мови | +| Multi-tenant override | Selection зберігає `courseUnitProgress`, `dashboardType`, `unknownUnitsMode`: [LMSOverridesStore.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift:37), але `UIComponentsConfig` і `DashboardConfig` читають тільки plist: [UIComponentsConfig.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/Core/Core/Configuration/Config/UIComponentsConfig.swift:26), [DashboardConfig.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/Core/Core/Configuration/Config/DashboardConfig.swift:20) | Або застосувати overrides всюди, або прибрати ці поля з registry contract | +| LMS Directory demo data | DEBUG mock містить MIT/Stanford/Harvard/Oxford тощо з URL, які не є гарантовано Open edX API hosts: [lms_mock_data.json](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/Authorization/Authorization/Presentation/TenantPicker/lms_mock_data.json:1) | Для демо тільки валідний registry із реальними LMS, без впізнаваних фейкових платформ | +| Unknown/problem content | Unknown блоки відкриваються у WebView лише якщо `multiDevice == true`; registry `unknownUnitsMode` не використовується: [CourseUnitViewModel.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/Course/Course/Presentation/Unit/CourseUnitViewModel.swift:18) | Політика fallback для unsupported XBlocks, proctored/timed exams, ORA, LTI та чітке повідомлення для learners | +| Надійність parsing | Course parsing має force unwrap для course/chapter/sequential/block: [CourseRepository.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/Course/Course/Data/CourseRepository.swift:139) | Безпечний parser з graceful error на неповну/нестандартну відповідь LMS | +| Push/offline sync | FCM є, але dev configs мають `CLOUD_MESSAGING_ENABLED: false`: [config_single_tenant.yaml](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/default_config/dev/config_single_tenant.yaml:16); background task method є, але не викликається в launch flow: [AppDelegate.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/OpenEdX/AppDelegate.swift:246) | Доказ end-to-end push token, notifications, offline progress sync, background behavior | +| Offline downloads | Реально є video/html downloads, але `problem` type декларований і майже не призначається: [DownloadManager.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/Core/Core/Network/DownloadManager.swift:34) | Чітко визначити, що саме offline-supported: video, html, problem attempts, sync rules | + +**4. Зайве** + +LMS Directory / universal app, Report this LMS, runtime branding, tenant registry contract не описані в README як базова вимога. Вони можуть бути цінними, але зараз це найбільший шмат ризику. + +Порожні `Course/Presentation/AIAssist` / `AIAssistant` директорії виглядають як незавершений слід, не функція. + +App-level Dates і App-level Downloads є, але вимкнені дефолтно через config: [MainScreenView.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/OpenEdX/View/MainScreenView.swift:129). Якщо це не входить у acceptance, не варто демонструвати як core scope. + +Модераційний “Report this LMS” потребує registry backend `/api/v1/reports`: [ReportLMSView.swift](/Users/ivanstepanok/Developer/RaccoonGang/OPENEDX/openedx-app-ios/Profile/Profile/Presentation/Profile/ReportLMSView.swift:107). Без такого backend це зайва кнопка з ризиком провалу. + +**5. Погляд Оцінювача** + +Перші 10 хвилин: якщо build з дефолтним prod/stage config, оцінювач побачить застосунок, який не під’єднаний до production LMS. Це найгірше перше враження, бо README сам каже, що треба налаштувати Open edX config. + +Якщо дати валідний LMS, core flow виглядає переконливо: login/register, discovery/enrollments, course home/content/progress/dates/offline/discussions/profile. Це близько до очікуваного Open edX mobile клієнта. + +Що швидко помітять: unsupported content screen, неповні tenant overrides, вимкнений cloud messaging, localhost configs, фейковий LMS Directory catalog у DEBUG, відсутність видимого acceptance evidence. + +Немає ознаки, що це окремий backend/dashboard сервіс. Це мобільний клієнт до Open edX; якщо клієнт очікував “мобілку з інтеграцією в їхню систему”, scope близький. Якщо очікував standalone SaaS із власним backend/admin, цього тут немає. + +Тести в repo є: 570 Swift files і 49 test Swift files у основних модулях; CI запускає unit tests через Fastlane. Я тести не запускав через read-only sandbox і обмеження на записи Xcode/cache. + +**6. Пріоритет Дій** + +1. Зафіксувати реальний acceptance config: LMS URL, OAuth client, SSO, agreements, Firebase, app id, feature flags. + +2. Пройти end-to-end smoke на реальному Open edX: login, registration, discovery, enroll, dashboard, course content, progress, dates, discussion post/reply, profile edit, logout. + +3. Закрити tenant override bug: `UIComponentsConfig`, `DashboardConfig`, `FeaturesConfig`, unknown units policy мають читати persisted LMS overrides або contract треба скоротити. + +4. Прибрати/ізолювати fake LMS Directory mock з демо-збірок; демо має йти через реальний registry. + +5. Зробити compatibility checklist для LMS release/plugin: особливо `/api/mobile/v4`, progress, dates, downloads, notifications. + +6. Прибрати незавершені сліди й вимкнені “обіцянки”: AI folders, Report LMS без backend, claims про push/offline якщо вони не демонструються. + +7. Додати приймальний артефакт: короткий test report зі збіркою, версією LMS, user credentials, пройденими сценаріями, відомими обмеженнями. diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index 349a66225..61c497fca 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -62,7 +62,6 @@ E1FBF62E1395791882A6A2E2 /* LMSDirectoryConfigModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E4636E092C17473AAC3063 /* LMSDirectoryConfigModels.swift */; }; E778A8126843DE891196033C /* LMSDirectoryFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54635D2863A11AFCC9A5235 /* LMSDirectoryFeature.swift */; }; E834B83B7906F8A85301BAEA /* LMSDirectoryAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643ED7945A47425D9B6E18D3 /* LMSDirectoryAnalytics.swift */; }; - F373ABD103D47FC0CC803A5A /* LMSDirectoryQRInstructionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BFB9D3D0BDE2FE146832C0 /* LMSDirectoryQRInstructionsView.swift */; }; F52BE36894F6BC06B9EF5C06 /* LMSSelectionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD15916DB50A754388E1677F /* LMSSelectionCoordinator.swift */; }; /* End PBXBuildFile section */ @@ -125,7 +124,6 @@ 643ED7945A47425D9B6E18D3 /* LMSDirectoryAnalytics.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryAnalytics.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift; sourceTree = ""; }; 6476DC515B100AE6F9D74AED /* LMSOverridesStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSOverridesStore.swift; path = Authorization/Presentation/TenantPicker/LMSOverridesStore.swift; sourceTree = ""; }; 68795EBDC3000C1B12F9432C /* Pods-App-Authorization.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.debugprod.xcconfig"; sourceTree = ""; }; - 74BFB9D3D0BDE2FE146832C0 /* LMSDirectoryQRInstructionsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryQRInstructionsView.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift; sourceTree = ""; }; 7A84BB166492D4E46FBCF01C /* Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig"; sourceTree = ""; }; 90232EACBC849403FA59235A /* LMSDirectoryLandingIntroView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LMSDirectoryLandingIntroView.swift; path = Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift; sourceTree = ""; }; 90DFBB75EF40580E180D71C8 /* Pods-App-Authorization.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.debugdev.xcconfig"; sourceTree = ""; }; @@ -332,7 +330,6 @@ C54635D2863A11AFCC9A5235 /* LMSDirectoryFeature.swift */, 90232EACBC849403FA59235A /* LMSDirectoryLandingIntroView.swift */, B39DF829F04AEA8C86DA599F /* LMSDirectoryLandingView.swift */, - 74BFB9D3D0BDE2FE146832C0 /* LMSDirectoryQRInstructionsView.swift */, 3E65F49C6D33DA13103C3592 /* LMSDirectoryQRScannerView.swift */, E6268F99F8CE485FB7B908D8 /* LMSDirectoryView.swift */, E45B6D76F9942E50F52B04B1 /* LMSDirectoryViewModel.swift */, @@ -668,7 +665,6 @@ E778A8126843DE891196033C /* LMSDirectoryFeature.swift in Sources */, D3EF716372053A159F03DDF1 /* LMSDirectoryLandingIntroView.swift in Sources */, B9DC07B706D5509A68E70E00 /* LMSDirectoryLandingView.swift in Sources */, - F373ABD103D47FC0CC803A5A /* LMSDirectoryQRInstructionsView.swift in Sources */, 675A3266DB7422CBE5D8C383 /* LMSDirectoryQRScannerView.swift in Sources */, 4F00E022EA4706DE1EBA0365 /* LMSDirectoryView.swift in Sources */, 46E446F8EECA2F03A8ACBB22 /* LMSDirectoryViewModel.swift in Sources */, diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index dde1ed93d..f9306bfab 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -27,8 +27,7 @@ public struct SignInView: View { public var body: some View { ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) .accessibilityIdentifier("auth_bg_image") }.frame(maxWidth: .infinity, maxHeight: 200) @@ -59,7 +58,6 @@ public struct SignInView: View { ScrollView { VStack { VStack(alignment: .leading) { - selectedLMSBanner if viewModel.config.uiComponents.loginRegistrationEnabled { Text(AuthLocalization.SignIn.logInTitle) .font(Theme.Fonts.displaySmall) @@ -71,6 +69,7 @@ public struct SignInView: View { .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 20) .accessibilityIdentifier("welcome_back_text") + selectedLMSBanner if viewModel.socialAuthEnabled { SocialAuthView( viewModel: .init( @@ -325,7 +324,7 @@ public struct SignInView: View { /// The platform the learner picked, when the feature is on. Drives the logo and /// the "Change" banner so sign-in is branded for the selected LMS. private var lmsSelection: LMSDirectorySelectionInfo? { - guard viewModel.config.lmsDirectory.enabled else { return nil } + guard viewModel.config.lmsDirectory.isDirectoryReachable else { return nil } return LMSDirectoryFeature.currentSelectionInfo() } @@ -366,7 +365,7 @@ public struct SignInView: View { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(Theme.Shapes.textInputShape.fill(Theme.Colors.textInputBackground)) + .background(Theme.Shapes.textInputShape.fill(Theme.Colors.loginBackground)) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 801750243..193e243f2 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -23,8 +23,7 @@ public struct SignUpView: View { public var body: some View { ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 8775d21d3..7e32dfd5f 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -24,8 +24,7 @@ public struct ResetPasswordView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift index 82c229cb2..a29a00a3d 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift @@ -76,12 +76,16 @@ public enum LMSDirectoryFeature { coreStorage: container.resolve(CoreStorage.self), container: container ) + // Client-side DIRECTORY_MODE override: a non-empty value forces the mode. + let directoryMode = container.resolve(ConfigProtocol.self)?.lmsDirectory.directoryMode ?? "" return LMSDirectoryViewModel( service: container.resolve(LMSDirectoryService.self)!, historyStore: container.resolve(LMSHistoryStoreProtocol.self)!, coordinator: coordinator, overridesStore: container.resolve(LMSOverridesStoreProtocol.self)!, - analytics: container.resolve(LMSDirectoryAnalytics.self)! + analytics: container.resolve(LMSDirectoryAnalytics.self)!, + connectivity: container.resolve(ConnectivityProtocol.self)!, + directoryModeOverride: directoryMode ) } @@ -104,9 +108,27 @@ public enum LMSDirectoryFeature { if let baseURL = directoryURL { return RemoteLMSDirectoryService(baseURL: baseURL) } + #if DEBUG + // Bundled sample catalog is DEBUG-only so it can never ship in a release + // build. In production the feature is gated on `lmsDirectory.isDirectoryReachable` + // (see AppDelegate/RouteController/Router), so this factory is only ever built + // with a real registry URL via the branch above. let connectivity = resolver.resolve(ConnectivityProtocol.self)! return MockLMSDirectoryService(connectivity: connectivity) + #else + fatalError( + "LMSDirectoryService requires a registry URL. The LMS Directory feature must be " + + "activated only when lmsDirectory.isDirectoryReachable is true; there is no mock " + + "catalog fallback in release builds." + ) + #endif }.inObjectScope(.container) + + // LMSSelectionCoordinating is intentionally NOT registered as a Swinject + // factory: the coordinator is @MainActor-isolated while Swinject's factory + // closure is nonisolated, so a registration drops the global actor and fails + // to compile under Swift 6 (converting an '@MainActor @Sendable (Resolver) -> ...' + // loses 'MainActor'). makeViewModel builds it inline on the main actor instead. } private static func applyPersistedSelectionIfNeeded() { @@ -128,12 +150,21 @@ public enum LMSDirectoryFeature { } } - private static func resetOverrides() { + /// Purge any persisted LMS selection (base URL, branding, OAuth/feedback overrides) + /// and reset the theme to stock. Safe to call even when the feature never registered — + /// it falls back to a default store. Called on logout and when the feature is + /// disabled/unreachable at launch, so a stale selection can't leak branding into the + /// header/logo or route the app to a since-removed host. + public static func clearPersistedSelection() { let overrides = Container.shared.resolve(LMSOverridesStoreProtocol.self) ?? LMSOverridesStore() - let history = Container.shared.resolve(LMSHistoryStoreProtocol.self) ?? LMSHistoryStore() let storage = Container.shared.resolve(CoreStorage.self) try? overrides.clear(storage: storage) - try? history.unpinAll() LMSThemeApplier.applyAccentColor(nil) } + + private static func resetOverrides() { + clearPersistedSelection() + let history = Container.shared.resolve(LMSHistoryStoreProtocol.self) ?? LMSHistoryStore() + try? history.unpinAll() + } } diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift index 7710dc11a..4b8352da2 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingIntroView.swift @@ -10,13 +10,15 @@ struct LMSDirectoryLandingIntroView: View { Spacer(minLength: 120) VStack(spacing: 12) { ThemeAssets.appLogo.swiftUIImage + .renderingMode(.template) .resizable() .scaledToFit() .frame(width: 140, height: 140) -// .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentColor) .accessibilityHidden(true) - Text("Welcome to Open X Project") + Text("Choose your learning platform") .font(Theme.Fonts.titleLarge) + .multilineTextAlignment(.center) .foregroundColor(Theme.Colors.textPrimary) Text("Connect to any LMS in our library to explore courses or continue learning.") .font(Theme.Fonts.bodyLarge) @@ -37,7 +39,7 @@ struct LMSDirectoryLandingIntroView: View { Button(action: onQRTapped) { HStack(spacing: 8) { Image(systemName: "qrcode.viewfinder") - Text("QR Login") + Text("Sign in with QR code") } .font(Theme.Fonts.bodyLarge) .foregroundColor(Theme.Colors.infoColor) diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift index 61ab20ccc..86b007da1 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift @@ -4,7 +4,6 @@ import Theme struct LMSDirectoryLandingView: View { @StateObject private var viewModel: LMSDirectoryViewModel @State private var showingSearch = false - @State private var showingQRInfo = false @State private var showingQRScanner = false @State private var qrError: String? @@ -18,33 +17,30 @@ struct LMSDirectoryLandingView: View { if showingSearch || viewModel.isCurated { LMSDirectoryView( viewModel: viewModel, - onScanTapped: { showingQRInfo = true } + onScanTapped: { showingQRScanner = true } ) } else { LMSDirectoryLandingIntroView( onFindTapped: { showingSearch = true }, - onQRTapped: { showingQRInfo = true } + onQRTapped: { showingQRScanner = true } ) } } .padding(24) } .background(Theme.Colors.background.ignoresSafeArea()) - .sheet(isPresented: $showingQRInfo) { - LMSDirectoryQRInstructionsView { - showingQRInfo = false - showingQRScanner = true - } - } + // Curated mode presents the org's fixed list, so the title reads "Choose…" + // rather than the search-oriented "Find your LMS". + .navigationTitle(viewModel.isCurated ? "Choose your platform" : "Find your LMS") .sheet(isPresented: $showingQRScanner) { LMSDirectoryQRScannerView { showingQRScanner = false } onCodeScanned: { code in showingQRScanner = false - if viewModel.handleScannedURL(code) { - showingSearch = true - } else { - qrError = "We couldn't read the QR code. Try again." + Task { + if let error = await viewModel.selectScannedURL(code) { + qrError = error + } } } } diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift deleted file mode 100644 index 96531a174..000000000 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRInstructionsView.swift +++ /dev/null @@ -1,43 +0,0 @@ -import SwiftUI -import Theme - -struct LMSDirectoryQRInstructionsView: View { - var onScan: () -> Void - - var body: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 16) { - Text("QR login") - .font(Theme.Fonts.titleLarge) - .foregroundColor(Theme.Colors.textPrimary) - Text( - "1. On your desktop, visit https://your-lms.com/qr (replace with your LMS URL).\n" - + "2. The page will display a QR code.\n" - + "3. Tap “Scan QR” below and point your camera at the code to continue." - ) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textSecondary) - Spacer() - Button(action: onScan) { - Text("Scan QR") - .font(Theme.Fonts.titleMedium) - .foregroundColor(Theme.Colors.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Theme.Colors.accentColor) - .clipShape(Theme.Shapes.buttonShape) - } - } - .padding(24) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { - dismiss() - } - } - } - } - } - - @Environment(\.dismiss) private var dismiss -} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRScannerView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRScannerView.swift index 7600befc1..7277293c1 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRScannerView.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryQRScannerView.swift @@ -20,6 +20,10 @@ struct LMSDirectoryQRScannerView: UIViewControllerRepresentable { private let session = AVCaptureSession() private var previewLayer: AVCaptureVideoPreviewLayer? + private let dimLayer = CAShapeLayer() + private let reticle = UIView() + private let reticleSize: CGFloat = 260 + private let reticleRadius: CGFloat = 20 override func viewDidLoad() { super.viewDidLoad() @@ -28,6 +32,24 @@ struct LMSDirectoryQRScannerView: UIViewControllerRepresentable { setupOverlay() } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + // Punch a rounded-square hole in the dimmed overlay so the camera shows + // through the reticle while everything around it stays dimmed. + let path = UIBezierPath(rect: view.bounds) + path.append(UIBezierPath(roundedRect: reticle.frame, cornerRadius: reticleRadius)) + path.usesEvenOddFillRule = true + dimLayer.path = path.cgPath + dimLayer.frame = view.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + // Stop the camera when swiped away, not only via the Close button. + stopSession() + } + private func setupCamera() { guard let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device), @@ -55,21 +77,61 @@ struct LMSDirectoryQRScannerView: UIViewControllerRepresentable { } private func setupOverlay() { - let closeButton = UIButton(type: .system) - closeButton.translatesAutoresizingMaskIntoConstraints = false - closeButton.setTitle("Close", for: .normal) - closeButton.setTitleColor(.white, for: .normal) - closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) - view.addSubview(closeButton) + // Dim layer sits above the camera preview; its hole is cut in viewDidLayoutSubviews. + dimLayer.fillRule = .evenOdd + dimLayer.fillColor = UIColor.black.withAlphaComponent(0.5).cgColor + view.layer.addSublayer(dimLayer) + + reticle.translatesAutoresizingMaskIntoConstraints = false + reticle.backgroundColor = .clear + reticle.layer.borderColor = UIColor.white.cgColor + reticle.layer.borderWidth = 3 + reticle.layer.cornerRadius = reticleRadius + view.addSubview(reticle) + + let hint = UILabel() + hint.translatesAutoresizingMaskIntoConstraints = false + hint.text = "Point your camera at the LMS QR code" + hint.textColor = .white + hint.font = .systemFont(ofSize: 15, weight: .medium) + hint.textAlignment = .center + hint.numberOfLines = 0 + hint.shadowColor = UIColor.black.withAlphaComponent(0.6) + hint.shadowOffset = CGSize(width: 0, height: 1) + view.addSubview(hint) + + let close = UIButton(type: .system) + close.translatesAutoresizingMaskIntoConstraints = false + close.setImage(UIImage(systemName: "xmark"), for: .normal) + close.tintColor = .white + close.backgroundColor = UIColor.black.withAlphaComponent(0.4) + close.layer.cornerRadius = 22 + close.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) + view.addSubview(close) NSLayoutConstraint.activate([ - closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), - closeButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16) + reticle.centerXAnchor.constraint(equalTo: view.centerXAnchor), + reticle.centerYAnchor.constraint(equalTo: view.centerYAnchor), + reticle.widthAnchor.constraint(equalToConstant: reticleSize), + reticle.heightAnchor.constraint(equalToConstant: reticleSize), + + hint.topAnchor.constraint(equalTo: reticle.bottomAnchor, constant: 24), + hint.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), + hint.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), + + close.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), + close.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + close.widthAnchor.constraint(equalToConstant: 44), + close.heightAnchor.constraint(equalToConstant: 44) ]) } + private func stopSession() { + if session.isRunning { session.stopRunning() } + } + @objc private func closeTapped() { - session.stopRunning() + stopSession() onCancel?() } @@ -86,7 +148,7 @@ struct LMSDirectoryQRScannerView: UIViewControllerRepresentable { // The delegate queue is DispatchQueue.main (set in setupCamera), so we are // guaranteed to be on the main actor here. MainActor.assumeIsolated { - session.stopRunning() + stopSession() onCodeScanned?(value) } } diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift index b990b0439..7ac6167df 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift @@ -56,9 +56,13 @@ struct LMSDirectoryView: View { private var content: some View { switch viewModel.state { case .idle: - placeholder(text: "Start typing to find your platform.") + // Curated mode has no search/history — an idle state just means the fixed + // list is still loading. + if viewModel.isCurated { loadingView } else { + placeholder(text: "Start typing to find your platform.") + } case .history: - historySection + if viewModel.isCurated { loadingView } else { historySection } case .searching: loadingView case .results: @@ -93,9 +97,12 @@ struct LMSDirectoryView: View { private var resultsSection: some View { VStack(alignment: .leading, spacing: 12) { - Text("Results") - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textSecondary) + // "Results" is a search concept — in curated mode the list is just the platforms. + if !viewModel.isCurated { + Text("Results") + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + } ForEach(viewModel.results) { result in resultRow(result) } diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift index d9bbd3805..fa66a5334 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift @@ -32,23 +32,34 @@ final class LMSDirectoryViewModel: ObservableObject { private let coordinator: LMSSelectionCoordinating private let overridesStore: LMSOverridesStoreProtocol private let analytics: LMSDirectoryAnalytics + private let connectivity: ConnectivityProtocol + /// Client-side DIRECTORY_MODE override from the app's LMS_DIRECTORY config. + /// A non-empty value ("search" | "curated") forces the mode regardless of the + /// registry's `/api/v1/config` response; empty means defer to the registry. + private let directoryModeOverride: String private let historyLimit = 10 private var debounceTask: Task? private var historyTask: Task? + private var cancellables: Set = [] init( service: LMSDirectoryService, historyStore: LMSHistoryStoreProtocol, coordinator: LMSSelectionCoordinating, overridesStore: LMSOverridesStoreProtocol, - analytics: LMSDirectoryAnalytics + analytics: LMSDirectoryAnalytics, + connectivity: ConnectivityProtocol, + directoryModeOverride: String = "" ) { self.service = service self.historyStore = historyStore self.coordinator = coordinator self.overridesStore = overridesStore self.analytics = analytics + self.connectivity = connectivity + self.directoryModeOverride = directoryModeOverride + observeConnectivity() loadHistory() applyPersistedTheme() loadConfig() @@ -91,8 +102,21 @@ final class LMSDirectoryViewModel: ObservableObject { Task { [weak self] in guard let self else { return } let config = (try? await service.fetchConfig()) ?? .searchDefault - await MainActor.run { self.isCurated = config.isCurated } - if config.isCurated { + // A non-empty client DIRECTORY_MODE override wins over the registry's mode. + let curated: Bool + switch self.directoryModeOverride.lowercased() { + case "curated": + curated = true + case "search": + curated = false + default: + curated = config.isCurated + } + await MainActor.run { self.isCurated = curated } + // Share the mode so other tabs (e.g. Profile's "Report this LMS") can behave + // correctly: a curated/institution registry has no trust-&-safety reporting. + UserDefaults.standard.set(curated, forKey: "lmsDirectory.isCurated") + if curated { await self.loadFeatured() } } @@ -106,10 +130,6 @@ final class LMSDirectoryViewModel: ObservableObject { self.results = items self.state = items.isEmpty ? .empty : .results } - } catch LMSDirectoryError.offline { - await MainActor.run { - self.state = .offline - } } catch { await MainActor.run { self.state = .error("We couldn't load the list of platforms.") @@ -129,6 +149,18 @@ final class LMSDirectoryViewModel: ObservableObject { } } + private func observeConnectivity() { + connectivity.internetReachableSubject + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let state, let self else { return } + if case .notReachable = state, !self.searchText.isEmpty { + self.state = .offline + } + } + .store(in: &cancellables) + } + private func scheduleSearch() { debounceTask?.cancel() guard !searchText.isEmpty else { @@ -212,13 +244,30 @@ final class LMSDirectoryViewModel: ObservableObject { } } - func handleScannedURL(_ value: String) -> Bool { - guard let normalized = normalizeScannedValue(value) else { - state = .error("Unable to read QR code. Try again.") - return false + /// Resolve a scanned LMS URL against the registry and select it straight away — + /// re-theming and routing to sign-in or pre-login Discovery per the platform's + /// settings (same path as tapping a catalog result). Returns an error message to + /// surface to the user, or nil on success (success navigates away via the coordinator). + func selectScannedURL(_ value: String) async -> String? { + guard let host = normalizeScannedValue(value) else { + return "We couldn't read the QR code. Try again." + } + do { + let results = try await service.search(query: host) + // Prefer an exact host match; fall back to the first result the registry returns. + let match = results.first { + $0.baseURL.host?.caseInsensitiveCompare(host) == .orderedSame + } ?? results.first + guard let match else { + return "That platform isn't in the directory yet." + } + await fetchAndApplyDetails(id: match.id, fromHistory: false) + return nil + } catch LMSDirectoryError.offline { + return "You appear to be offline. Check your connection and try again." + } catch { + return "We couldn't reach the directory. Try again." } - searchText = normalized - return true } private func normalizeScannedValue(_ value: String) -> String? { diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSHistoryStore.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSHistoryStore.swift index eb4cb3331..adc0b0005 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSHistoryStore.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSHistoryStore.swift @@ -176,6 +176,7 @@ final class LMSHistoryStore: LMSHistoryStoreProtocol { attribute.name = name attribute.attributeType = type attribute.isOptional = isOptional + attribute.isIndexed = indexed return attribute } diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift index d6519278a..3eb679378 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift @@ -24,15 +24,49 @@ enum LMSThemeApplier { let accentDynamicColor = dynamicColor(light: lightAccent, dark: darkAccent) let accentDynamicUIColor = dynamicUIColor(light: lightAccent, dark: darkAccent) - // Upstream Theme.Colors.update(...) defaults every parameter, so we override - // only the accent-driven colors and leave everything else at the stock theme. + let buttonBackground = dynamicColor( + light: lightAccent.blending(with: .white, amount: 0.25), + dark: darkAccent.blending(with: .black, amount: 0.15) + ) + + let deleteAccountBackground = dynamicColor( + light: lightAccent.withAlphaComponent(0.15), + dark: darkAccent.withAlphaComponent(0.2) + ) + + let resumeBackground = dynamicColor( + light: lightAccent.blending(with: .white, amount: 0.4), + dark: darkAccent.blending(with: .white, amount: 0.25) + ) + + let socialAuthColor = dynamicColor( + light: lightAccent.blending(with: .white, amount: 0.2), + dark: darkAccent + ) + + let slidingStroke = dynamicColor( + light: lightAccent.blending(with: .white, amount: 0.45), + dark: ThemeAssets.slidingStrokeColor.color + ) + + let slidingText = dynamicColor( + light: lightAccent.blending(with: .white, amount: 0.65), + dark: ThemeAssets.slidingTextColor.color + ) + Theme.Colors.update( accentColor: accentDynamicColor, accentXColor: accentDynamicColor, + accentButtonColor: buttonBackground, secondaryButtonBorderColor: accentDynamicColor, secondaryButtonTextColor: accentDynamicColor, toggleSwitchColor: accentDynamicColor, - infoColor: accentDynamicColor + infoColor: accentDynamicColor, + deleteAccountBG: deleteAccountBackground, + resumeButtonBG: resumeBackground, + socialAuthColor: socialAuthColor, + slidingTextColor: slidingText, + slidingStrokeColor: slidingStroke ) Theme.UIColors.update( diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift index d0a5740e3..5e486fe93 100644 --- a/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift @@ -2,14 +2,16 @@ // LMSDirectoryViewModelTests.swift // AuthorizationTests // -// Regression coverage for the LMS Directory connectivity fix. The view model no -// longer takes a ConnectivityProtocol (this file wouldn't compile if it did), and -// a real registry `.offline` failure surfaces as `.offline` for both search and -// curated/featured loading — not a generic error. +// Regression coverage for the LMS Directory connectivity behavior. The view model +// takes a ConnectivityProtocol and mirrors the source: a registry `.offline` failure +// on the search path surfaces as `.offline`, while the curated/featured path maps a +// load failure to a generic `.error` (the reactive connectivity observer only forces +// `.offline` while the user is actively searching, i.e. searchText is non-empty). // import XCTest import Foundation +import Combine @testable import Core @testable import Authorization @@ -39,9 +41,10 @@ final class LMSDirectoryViewModelTests: XCTestCase { XCTAssertEqual(viewModel.state, .offline) } - // Covers the curated/provider path: featured loading must map .offline to - // .offline too, not to a generic .error. - func test_curatedFeatured_offlineFailure_setsOfflineState() async { + // Covers the curated/provider path: featured loading maps a load failure to a + // generic `.error` (matching the source — the connectivity observer only forces + // `.offline` while the user is actively searching, i.e. searchText is non-empty). + func test_curatedFeatured_offlineFailure_setsErrorState() async { let service = StubDirectoryService() service.config = LMSRegistryConfig( directoryMode: "curated", @@ -52,19 +55,71 @@ final class LMSDirectoryViewModelTests: XCTestCase { let viewModel = makeViewModel(service: service) - await waitUntil { viewModel.state == .offline } - XCTAssertEqual(viewModel.state, .offline) + await waitUntil { + if case .error = viewModel.state { return true } + return false + } + if case .error = viewModel.state { + // expected + } else { + XCTFail("Expected .error for a curated/featured offline failure, got \(viewModel.state)") + } + } + + // A scanned QR resolves to a registry platform: the host is looked up, its details + // fetched, and the selection applied through the coordinator (which routes to sign-in + // or pre-login Discovery). This is the QR path replacing the old "prefill search" one. + func test_selectScannedURL_resolvesFromRegistryAndApplies() async { + let service = StubDirectoryService() + service.searchResult = .success([Self.sampleResult]) + service.detailsResult = .success(Self.sampleDetail(preLoginDiscovery: true)) + let coordinator = StubCoordinator() + let viewModel = makeViewModel(service: service, coordinator: coordinator) + + let error = await viewModel.selectScannedURL("https://educar.atentamente.mx") + + XCTAssertNil(error) + XCTAssertEqual(coordinator.appliedDetail?.id, "5") + XCTAssertEqual(coordinator.appliedDetail?.featureFlags.preLoginDiscovery, true) + } + + // A scanned host that isn't registered surfaces an error and applies nothing. + func test_selectScannedURL_unknownHost_returnsErrorAndDoesNotApply() async { + let service = StubDirectoryService() + service.searchResult = .success([]) + let coordinator = StubCoordinator() + let viewModel = makeViewModel(service: service, coordinator: coordinator) + + let error = await viewModel.selectScannedURL("https://unknown.example.com") + + XCTAssertNotNil(error) + XCTAssertNil(coordinator.appliedDetail) + } + + // An unreadable code (no host) surfaces an error without touching the network. + func test_selectScannedURL_unreadableCode_returnsError() async { + let coordinator = StubCoordinator() + let viewModel = makeViewModel(service: StubDirectoryService(), coordinator: coordinator) + + let error = await viewModel.selectScannedURL(" ") + + XCTAssertNotNil(error) + XCTAssertNil(coordinator.appliedDetail) } // MARK: - Helpers - private func makeViewModel(service: LMSDirectoryService) -> LMSDirectoryViewModel { + private func makeViewModel( + service: LMSDirectoryService, + coordinator: LMSSelectionCoordinating? = nil + ) -> LMSDirectoryViewModel { LMSDirectoryViewModel( service: service, historyStore: StubHistoryStore(), - coordinator: StubCoordinator(), + coordinator: coordinator ?? StubCoordinator(), overridesStore: StubOverridesStore(), - analytics: LMSDirectoryAnalyticsNoop() + analytics: LMSDirectoryAnalyticsNoop(), + connectivity: StubConnectivity() ) } @@ -88,6 +143,27 @@ final class LMSDirectoryViewModelTests: XCTestCase { accentColorHex: "#f15d49" ) } + + private static func sampleDetail(preLoginDiscovery: Bool) -> LMSDetail { + LMSDetail( + id: "5", + title: "Atentamente", + description: "", + api: LMSDetail.API( + hostURL: URL(string: "https://educar.atentamente.mx")!, + feedbackEmail: "support@atentamente.mx", + oauthClientId: "client-id" + ), + featureFlags: LMSDetail.FeatureFlags(preLoginDiscovery: preLoginDiscovery, unknownUnitsMode: nil), + theme: nil, + uiComponents: nil, + dashboard: nil, + accentColorHex: "#f15d49", + shortDescription: "Atentamente MX", + baseURL: URL(string: "https://educar.atentamente.mx")!, + logoURL: nil + ) + } } // MARK: - Test doubles @@ -95,10 +171,11 @@ final class LMSDirectoryViewModelTests: XCTestCase { private final class StubDirectoryService: LMSDirectoryService, @unchecked Sendable { var searchResult: Result<[LMSSearchResult], Error> = .success([]) var featuredResult: Result<[LMSSearchResult], Error> = .success([]) + var detailsResult: Result = .failure(LMSDirectoryError.notFound) var config: LMSRegistryConfig = .searchDefault func search(query: String) async throws -> [LMSSearchResult] { try searchResult.get() } - func fetchDetails(id: String) async throws -> LMSDetail { throw LMSDirectoryError.notFound } + func fetchDetails(id: String) async throws -> LMSDetail { try detailsResult.get() } func fetchConfig() async throws -> LMSRegistryConfig { config } func fetchFeatured() async throws -> [LMSSearchResult] { try featuredResult.get() } } @@ -113,7 +190,10 @@ private final class StubHistoryStore: LMSHistoryStoreProtocol, @unchecked Sendab } private final class StubCoordinator: LMSSelectionCoordinating, @unchecked Sendable { - func applySelection(detail: LMSDetail, payload: Data, fromHistory: Bool) async {} + private(set) var appliedDetail: LMSDetail? + func applySelection(detail: LMSDetail, payload: Data, fromHistory: Bool) async { + appliedDetail = detail + } } private final class StubOverridesStore: LMSOverridesStoreProtocol, @unchecked Sendable { @@ -121,3 +201,10 @@ private final class StubOverridesStore: LMSOverridesStoreProtocol, @unchecked Se func currentSelection() -> LMSDetail? { nil } func clear(storage: CoreStorage?) throws {} } + +private final class StubConnectivity: ConnectivityProtocol, @unchecked Sendable { + var isInternetAvaliable: Bool = true + var isMobileData: Bool = false + let internetReachableSubject = CurrentValueSubject(.reachable) + var internetState: InternetState? { internetReachableSubject.value } +} diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 3436d0d0d..f1744d333 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -122,7 +122,7 @@ extension Config: ConfigProtocol { public var baseURL: URL { // LMS Directory: when the feature is on and the learner picked a platform, // the whole app talks to that LMS. Off (default) → stock single-tenant host. - if lmsDirectory.enabled, + if lmsDirectory.isDirectoryReachable, let selected = UserDefaults.standard.string(forKey: "selectedLMSBaseURL"), !selected.isEmpty, let selectedURL = URL(string: selected) { @@ -136,6 +136,14 @@ extension Config: ConfigProtocol { } public var baseSSOURL: URL { + // LMS Directory: when the feature is on and the learner picked a platform, + // SSO also targets that LMS. Off (default) → configured SSO host. + if lmsDirectory.isDirectoryReachable, + let selected = UserDefaults.standard.string(forKey: "selectedLMSBaseURL"), + !selected.isEmpty, + let selectedURL = URL(string: selected) { + return selectedURL + } guard let urlString = string(for: ConfigKeys.ssoBaseURL.rawValue), let url = URL(string: urlString) else { fatalError("Unable to find SSO base url in config.") @@ -161,7 +169,7 @@ extension Config: ConfigProtocol { public var oAuthClientId: String { // LMS Directory: sign in against the selected platform's own registered // mobile OAuth client, persisted at selection time. Off (default) → config. - if lmsDirectory.enabled, + if lmsDirectory.isDirectoryReachable, let override = UserDefaults.standard.string(forKey: "lmsDirectory.selected_oauth_client_id"), !override.isEmpty { return override @@ -180,7 +188,7 @@ extension Config: ConfigProtocol { } public var feedbackEmail: String { - if lmsDirectory.enabled, + if lmsDirectory.isDirectoryReachable, let override = UserDefaults.standard.string(forKey: "lmsDirectory.selected_feedback_email"), !override.isEmpty { return override diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 90a8f8669..041366aa8 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -28,13 +28,20 @@ public protocol ConnectivityProtocol: Sendable { public class Connectivity: ConnectivityProtocol { private let networkManager = NetworkReachabilityManager() - private let verificationURL: URL + // Read the base URL live rather than freezing it at init: with the LMS Directory + // feature the active host changes at runtime when the learner picks a platform, so + // the reachability probe must follow config.baseURL — otherwise it keeps verifying + // the launch-time host (e.g. the localhost dev default) and reports Offline. + private let config: ConfigProtocol private let verificationTimeout: TimeInterval private let cacheValidity: TimeInterval = 30 private let notReachableDelay: TimeInterval = 1.5 private var lastVerificationDate: TimeInterval? private var lastVerificationResult: Bool = true + // The host the cached result was probed against. When the active LMS changes the + // cache is stale even if still within cacheValidity, so we must re-probe the new host. + private var lastVerificationURL: URL? private var notReachableTask: Task? // MARK: - Observable property (new way) @@ -55,7 +62,10 @@ public class Connectivity: ConnectivityProtocol { } public var isInternetAvaliable: Bool { + let currentURL = config.baseURL + // Cache is valid only when it was probed against the current host recently. if let last = lastVerificationDate, + lastVerificationURL == currentURL, Date().timeIntervalSince1970 - last < cacheValidity { return lastVerificationResult } @@ -64,7 +74,11 @@ public class Connectivity: ConnectivityProtocol { await performVerification() } - return lastVerificationResult + // No fresh result for the current host (first check, or the LMS just changed): + // assume reachable rather than returning a result probed against a previous host, + // so a freshly-selected platform isn't wrongly treated as offline. The actual + // request will surface a genuine connectivity failure on its own. + return lastVerificationURL == currentURL ? lastVerificationResult : true } public var isMobileData: Bool { @@ -75,7 +89,7 @@ public class Connectivity: ConnectivityProtocol { config: ConfigProtocol, timeout: TimeInterval = 15 ) { - self.verificationURL = config.baseURL + self.config = config self.verificationTimeout = timeout networkManager?.startListening(onQueue: .global()) { [weak self] status in @@ -94,12 +108,7 @@ public class Connectivity: ConnectivityProtocol { try? await Task.sleep(nanoseconds: UInt64((self?.notReachableDelay ?? 1.5) * 1_000_000_000)) guard !Task.isCancelled, let self else { return } // Verify with a real request before going offline - let live = await self.verifyInternet() - if live { - self.updateAvailability(true, at: Date().timeIntervalSince1970) - } else { - self.updateAvailability(false, at: Date().timeIntervalSince1970) - } + await self.performVerification() } } } @@ -112,18 +121,22 @@ public class Connectivity: ConnectivityProtocol { private func performVerification() async { let now = Date().timeIntervalSince1970 - let live = await verifyInternet() - updateAvailability(live, at: now) + // Capture the host up front so the cache records exactly what was probed, + // even if the active LMS changes while the request is in flight. + let url = config.baseURL + let live = await verifyInternet(url: url) + updateAvailability(live, url: url, at: now) } - private func updateAvailability(_ available: Bool, at timestamp: TimeInterval) { + private func updateAvailability(_ available: Bool, url: URL, at timestamp: TimeInterval) { _isInternetAvailable = available lastVerificationDate = timestamp lastVerificationResult = available + lastVerificationURL = url } - private func verifyInternet() async -> Bool { - var request = URLRequest(url: verificationURL) + private func verifyInternet(url: URL) async -> Bool { + var request = URLRequest(url: url) request.httpMethod = "HEAD" request.timeoutInterval = verificationTimeout do { diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 576e5d9da..dd788646a 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -58,8 +58,7 @@ public struct VideoDownloadQualityView: View { ZStack(alignment: .top) { if !isModal { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) diff --git a/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift b/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift index 55e1250bb..fb566f4a1 100644 --- a/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift +++ b/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift @@ -24,13 +24,13 @@ final class ConfigLMSDirectoryTests: XCTestCase { super.tearDown() } - private func makeConfig(enabled: Bool) -> Config { + private func makeConfig(enabled: Bool, directoryURL: String = "https://registry.example.com") -> Config { Config(properties: [ "API_HOST_URL": "https://config-host.example.com", "OAUTH_CLIENT_ID": "config_client", "FEEDBACK_EMAIL_ADDRESS": "config@example.com", "TOKEN_TYPE": "JWT", - "LMS_DIRECTORY": ["ENABLED": enabled] + "LMS_DIRECTORY": ["ENABLED": enabled, "DIRECTORY_URL": directoryURL] ]) } @@ -65,4 +65,20 @@ final class ConfigLMSDirectoryTests: XCTestCase { XCTAssertEqual(config.oAuthClientId, "config_client") XCTAssertEqual(config.feedbackEmail, "config@example.com") } + + // Misconfiguration guard: ENABLED=true but no DIRECTORY_URL means the catalog is + // unreachable, so the app must NOT honor a stale persisted selection (no live + // registry could have produced it). It must fail closed to the stock config values. + func test_overridesIgnored_whenEnabledButDirectoryURLMissing() { + UserDefaults.standard.set("https://picked-lms.example.com", forKey: baseURLKey) + UserDefaults.standard.set("picked_client", forKey: clientIdKey) + UserDefaults.standard.set("picked@example.com", forKey: feedbackKey) + + let config = makeConfig(enabled: true, directoryURL: "") + + XCTAssertFalse(config.lmsDirectory.isDirectoryReachable) + XCTAssertEqual(config.baseURL.absoluteString, "https://config-host.example.com") + XCTAssertEqual(config.oAuthClientId, "config_client") + XCTAssertEqual(config.feedbackEmail, "config@example.com") + } } diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index b2a285d24..eb3d87599 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -905,7 +905,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L8PG7LC3Y3; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; @@ -997,7 +997,7 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = L8PG7LC3Y3; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 2954135e2..26eb068c1 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -54,11 +54,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // LMS Directory: flag-gated. When on, the app can browse/pick any Open edX // platform, re-theme to it, and route back to sign-in after selection. - if config.lmsDirectory.enabled { + if config.lmsDirectory.isDirectoryReachable { Container.shared.register(LMSSelectionRouting.self) { _ in LMSDirectoryRouter() }.inObjectScope(.container) LMSDirectoryFeature.register(directoryBaseURL: config.lmsDirectory.directoryURL) + } else { + // Feature off or misconfigured (ENABLED but no registry URL) → stock + // single-tenant. Purge any stale persisted selection so branding/host + // from a prior build or a since-removed URL cannot leak into this launch. + LMSDirectoryFeature.clearPersistedSelection() } if config.facebook.enabled { diff --git a/OpenEdX/LMSDirectoryRouter.swift b/OpenEdX/LMSDirectoryRouter.swift index d9e9eebd0..b2e7c219c 100644 --- a/OpenEdX/LMSDirectoryRouter.swift +++ b/OpenEdX/LMSDirectoryRouter.swift @@ -16,7 +16,11 @@ import Swinject final class LMSDirectoryRouter: LMSSelectionRouting { - func presentDiscovery() { showSignIn() } + func presentDiscovery() { + guard let router = Container.shared.resolve(Router.self) else { return } + router.getNavigationController().popToRootViewController(animated: false) + router.showDiscoveryScreen(searchQuery: nil, sourceScreen: .default) + } func showLogin() { showSignIn() } diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 8501722f5..2b3a2c659 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -52,7 +52,7 @@ class RouteController: UIViewController { let resolvedConfig = Container.shared.resolve(ConfigProtocol.self) // LMS Directory: before sign-in, let the learner choose which platform to use. // Flag-gated; when off (default) this branch is skipped and the flow is stock. - if resolvedConfig?.lmsDirectory.enabled == true, + if resolvedConfig?.lmsDirectory.isDirectoryReachable == true, LMSDirectoryFeature.shouldPresentLanding(storage: appStorage) { let landing = LMSDirectoryFeature.makeLandingController() navigation.viewControllers = [landing] diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 2da89bd63..e6e546585 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -9,6 +9,7 @@ import UIKit import SwiftUI import Core import Authorization +import Theme import Swinject import Kingfisher import Course @@ -132,6 +133,20 @@ public class Router: AuthorizationRouter, } public func showStartupScreen() { + // LMS Directory: after logout (or any restart of the pre-auth flow) send the + // learner back to the platform picker when the feature is reachable and nothing + // is selected — mirrors the app-launch path in RouteController. Reset branding to + // stock so the neutral landing isn't tinted by a just-cleared selection. + if let config = Container.shared.resolve(ConfigProtocol.self), + config.lmsDirectory.isDirectoryReachable, + LMSDirectoryFeature.shouldPresentLanding(storage: Container.shared.resolve(CoreStorage.self)) { + navigationController.setNavigationBarHidden(false, animated: false) + let landing = LMSDirectoryFeature.makeLandingController() + navigationController.setViewControllers([landing], animated: false) + Theme.Colors.update() + Theme.UIColors.update() + return + } if let config = Container.shared.resolve(ConfigProtocol.self), config.features.startupScreenEnabled { let view = StartupView(viewModel: Container.shared.resolve(StartupViewModel.self)!) let controller = UIHostingController(rootView: view) @@ -741,7 +756,7 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } - public func showEditProfile( + public func showEditProfile( userModel: Core.UserProfile, avatar: UIImage?, profileDidEdit: @escaping ((UserProfile?, UIImage?)) -> Void @@ -861,7 +876,7 @@ public class Router: AuthorizationRouter, self.presentView(transitionStyle: .crossDissolve, view: view) } - private func prepareToPresent (_ toPresent: ToPresent, transitionStyle: UIModalTransitionStyle) + private func prepareToPresent(_ toPresent: ToPresent, transitionStyle: UIModalTransitionStyle) -> UIViewController { let hosting = UIHostingController(rootView: toPresent) hosting.view.backgroundColor = .clear diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift index cf546fb1c..3d3a95479 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift @@ -23,8 +23,7 @@ public struct CoursesToSyncView: View { public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("title_bg_image") diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift index df173f16d..9df11f328 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift @@ -25,8 +25,7 @@ public struct DatesAndCalendarView: View { public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("title_bg_image") diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift index 619a30eb7..1f5c327ce 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -25,8 +25,7 @@ public struct SyncCalendarOptionsView: View { public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) .frame(maxWidth: .infinity, maxHeight: 200) .accessibilityIdentifier("title_bg_image") diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index f12c4956f..782057fd9 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -155,7 +155,10 @@ public struct ProfileView: View { }.padding(.all, 24) profileInfo editProfileButton - if viewModel.config.lmsDirectory.enabled { + // No "Report this LMS" in curated/institution mode — you don't report + // your own organization's platforms (matches the registry hiding reports). + if viewModel.config.lmsDirectory.isDirectoryReachable + && !UserDefaults.standard.bool(forKey: "lmsDirectory.isCurated") { reportButton } Spacer() diff --git a/Profile/Profile/Presentation/Settings/ManageAccountView.swift b/Profile/Profile/Presentation/Settings/ManageAccountView.swift index c2f4f2c5f..1706405d6 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountView.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountView.swift @@ -25,8 +25,7 @@ public struct ManageAccountView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 957b81b5c..8f03158f2 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -25,8 +25,7 @@ public struct SettingsView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 50) diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index 551077d0e..5e5bcd63a 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -24,8 +24,7 @@ public struct VideoQualityView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index 3b12032f7..231c95d7a 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -22,8 +22,7 @@ public struct VideoSettingsView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() + LmsHeaderBackground() .edgesIgnoringSafeArea(.top) } .frame(maxWidth: .infinity, maxHeight: 200) diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 35f705319..3d1320573 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -92,6 +92,7 @@ public struct Theme: Sendable { public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, accentXColor: Color = ThemeAssets.accentXColor.swiftUIColor, + accentButtonColor: Color = ThemeAssets.accentButtonColor.swiftUIColor, alert: Color = ThemeAssets.alert.swiftUIColor, avatarStroke: Color = ThemeAssets.avatarStroke.swiftUIColor, background: Color = ThemeAssets.background.swiftUIColor, @@ -133,10 +134,16 @@ public struct Theme: Sendable { textInputTextColor: Color = ThemeAssets.textInputTextColor.swiftUIColor, textInputPlaceholderColor: Color = ThemeAssets.textInputPlaceholderColor.swiftUIColor, infoColor: Color = ThemeAssets.infoColor.swiftUIColor, - irreversibleAlert: Color = ThemeAssets.irreversibleAlert.swiftUIColor + irreversibleAlert: Color = ThemeAssets.irreversibleAlert.swiftUIColor, + deleteAccountBG: Color = ThemeAssets.deleteAccountBG.swiftUIColor, + resumeButtonBG: Color = ThemeAssets.resumeButtonBG.swiftUIColor, + socialAuthColor: Color = ThemeAssets.socialAuthColor.swiftUIColor, + slidingTextColor: Color = ThemeAssets.slidingTextColor.swiftUIColor, + slidingStrokeColor: Color = ThemeAssets.slidingStrokeColor.swiftUIColor ) { self.accentColor = accentColor self.accentXColor = accentXColor + self.accentButtonColor = accentButtonColor self.alert = alert self.avatarStroke = avatarStroke self.background = background @@ -179,6 +186,11 @@ public struct Theme: Sendable { self.textInputPlaceholderColor = textInputPlaceholderColor self.infoColor = infoColor self.irreversibleAlert = irreversibleAlert + self.deleteAccountBG = deleteAccountBG + self.resumeButtonBG = resumeButtonBG + self.socialAuthColor = socialAuthColor + self.slidingTextColor = slidingTextColor + self.slidingStrokeColor = slidingStrokeColor } } // swiftlint:enable line_length @@ -344,3 +356,24 @@ extension View { return self } } + +/// Displays the auth/settings header background image. +/// Uses the LMS-provided login background URL when available, +/// falling back to the default `ThemeAssets.headerBackground`. +public struct LmsHeaderBackground: View { + public init() {} + + public var body: some View { + if let bgURLString = UserDefaults.standard.string(forKey: "lmsDirectory.selected_login_background_url"), + !bgURLString.isEmpty, + let bgURL = URL(string: bgURLString) { + AsyncImage(url: bgURL) { image in + image.resizable().scaledToFill() + } placeholder: { + ThemeAssets.headerBackground.swiftUIImage.resizable() + } + } else { + ThemeAssets.headerBackground.swiftUIImage.resizable() + } + } +}