From d969f4944d89ab2d2d5567017957abbd341b533e Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:40:31 +0300 Subject: [PATCH 1/5] feat: let a learner choose which Open edX platform to sign in to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build normally talks to exactly one site, named in config.yaml. With LMS_DIRECTORY enabled it instead shows the platforms listed in a JSON document, re-themes to whichever the learner picks — logo, accent colour, sign-in artwork — and signs in against that one. Logging out returns to the list, so a device can move between platforms without a new build. Off by default: with ENABLED false none of this runs. The document is the whole contract. It comes from a URL or from a file inside the app, and nothing downstream can tell which — so a build can ship its list and its images and never ask the network for either. Because the whole list arrives at once, every platform's sign-in background is known before anything is tapped, which is what makes it possible to warm that artwork while the learner is still choosing rather than showing a placeholder afterwards. Image fields carry either an http(s) address or the name of a file shipped with the app. One field, one rule: the alternative was a parallel set of *_asset fields and a precedence rule to go with them, which puts the rule in documentation instead of in the value. Theme.Images.headerBackground now holds a decoded image rather than a URL. The header used AsyncImage, which has no cache, so it refetched and flashed a placeholder on every appearance. Kingfisher does the fetching; Theme does not depend on it, because Core already depends on Theme. Documentation/LMS_DIRECTORY.md has the format, both delivery modes and a worked example. --- .swiftlint.yml | 1 + .../Generated/AppDatesMocks.generated.swift | 15 +- .../Authorization.xcodeproj/project.pbxproj | 88 +++++++ .../Presentation/Login/SignInView.swift | 65 +++++- .../Registration/SignUpView.swift | 3 +- .../Reset Password/ResetPasswordView.swift | 3 +- .../TenantPicker/LMSDirectoryAnalytics.swift | 9 + .../TenantPicker/LMSDirectoryFeature.swift | 144 ++++++++++++ .../LMSDirectoryLandingView.swift | 26 +++ .../TenantPicker/LMSDirectoryService.swift | 24 ++ .../TenantPicker/LMSDirectoryView.swift | 177 +++++++++++++++ .../TenantPicker/LMSDirectoryViewModel.swift | 131 +++++++++++ .../Presentation/TenantPicker/LMSModels.swift | 194 ++++++++++++++++ .../TenantPicker/LMSOverridesStore.swift | 79 +++++++ .../LMSSelectionCoordinator.swift | 68 ++++++ .../TenantPicker/LMSThemeApplier.swift | 175 ++++++++++++++ .../StaticLMSDirectoryService.swift | 196 ++++++++++++++++ .../Authorization/SwiftGen/Strings.swift | 22 ++ .../en.lproj/Localizable.strings | 10 + .../AuthorizationMocks.generated.swift | 15 +- .../LMSDirectoryViewModelTests.swift | 151 ++++++++++++ .../StaticLMSDirectoryServiceTests.swift | 214 ++++++++++++++++++ .../LMSDirectory/StubURLProtocol.swift | 33 +++ Core/Core.xcodeproj/project.pbxproj | 20 ++ Core/Core/Configuration/Config/Config.swift | 29 +++ .../Config/LMSDirectoryConfig.swift | 75 ++++++ Core/Core/Configuration/Connectivity.swift | 41 ++-- Core/Core/Configuration/LMSImageSource.swift | 76 +++++++ Core/Core/Data/CoreStorage.swift | 6 +- .../View/Base/VideoDownloadQualityView.swift | 3 +- .../ConfigLMSDirectoryTests.swift | 84 +++++++ .../LMSDirectoryConfigSourceTests.swift | 59 +++++ .../Configuration/LMSImageSourceTests.swift | 55 +++++ .../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 +- Documentation/LMS_DIRECTORY.md | 140 ++++++++++++ .../Generated/DownloadsMocks.generated.swift | 15 +- OpenEdX.xcodeproj/project.pbxproj | 172 +++++++++++++- OpenEdX/AppDelegate.swift | 17 +- OpenEdX/Data/AppStorage.swift | 15 ++ OpenEdX/Info.plist | 2 + OpenEdX/LMSDirectoryRouter.swift | 43 ++++ OpenEdX/RouteController.swift | 12 +- OpenEdX/Router.swift | 19 +- .../DatesAndCalendar/CoursesToSyncView.swift | 3 +- .../DatesAndCalendarView.swift | 3 +- .../SyncCalendarOptionsView.swift | 3 +- .../Presentation/Profile/ProfileView.swift | 6 +- .../Settings/ManageAccountView.swift | 3 +- .../Presentation/Settings/SettingsView.swift | 3 +- .../Settings/VideoQualityView.swift | 3 +- .../Settings/VideoSettingsView.swift | 3 +- .../Generated/ProfileMocks.generated.swift | 15 +- Theme/Theme/Theme.swift | 47 ++++ default_config/dev/config.yaml | 17 ++ default_config/prod/config.yaml | 17 ++ default_config/stage/config.yaml | 17 ++ 60 files changed, 2874 insertions(+), 67 deletions(-) create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift create mode 100644 Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.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/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/StaticLMSDirectoryService.swift create mode 100644 Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift create mode 100644 Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift create mode 100644 Authorization/AuthorizationTests/Presentation/LMSDirectory/StubURLProtocol.swift create mode 100644 Core/Core/Configuration/Config/LMSDirectoryConfig.swift create mode 100644 Core/Core/Configuration/LMSImageSource.swift create mode 100644 Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift create mode 100644 Core/CoreTests/Configuration/LMSDirectoryConfigSourceTests.swift create mode 100644 Core/CoreTests/Configuration/LMSImageSourceTests.swift create mode 100644 Documentation/LMS_DIRECTORY.md create mode 100644 OpenEdX/LMSDirectoryRouter.swift 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 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..0ba96c2e4 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,21 @@ 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 */; }; + 0D40DAEE442BC84AC56E9E79 /* StubURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6C779D00BF30C254684D3F /* StubURLProtocol.swift */; }; + 0FE969FB22F82F2D38C35356 /* LMSOverridesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6476DC515B100AE6F9D74AED /* LMSOverridesStore.swift */; }; + 314E58A9645028CD68A2B338 /* StaticLMSDirectoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7645C3630CAC8C86B8AA143F /* StaticLMSDirectoryService.swift */; }; + 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 */; }; + 935FEA69BE7D57F57D42BB86 /* LMSDirectoryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9EF18C82B8CA9A89481E82 /* LMSDirectoryViewModelTests.swift */; }; + 95F57194827709F98991ECF9 /* LMSThemeApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0922F56B70DDA9F2910C6C /* LMSThemeApplier.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 */; }; @@ -45,6 +55,10 @@ 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 */; }; + E778A8126843DE891196033C /* LMSDirectoryFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54635D2863A11AFCC9A5235 /* LMSDirectoryFeature.swift */; }; + E834B83B7906F8A85301BAEA /* LMSDirectoryAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643ED7945A47425D9B6E18D3 /* LMSDirectoryAnalytics.swift */; }; + E98677D626D2D111EAFFEEDB /* StaticLMSDirectoryServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76632219FE9EACAF3975E68F /* StaticLMSDirectoryServiceTests.swift */; }; + F52BE36894F6BC06B9EF5C06 /* LMSSelectionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD15916DB50A754388E1677F /* LMSSelectionCoordinator.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -92,13 +106,19 @@ 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 = ""; }; 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 = ""; }; 47BCFB7C19382EECF15131B6 /* Pods_App_Authorization.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Authorization.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; + 4C6C779D00BF30C254684D3F /* StubURLProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LMSDirectory/StubURLProtocol.swift; sourceTree = ""; }; 4FAE9B7FD61FF88C9C4FE1E8 /* Pods_App_Authorization_AuthorizationTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Authorization_AuthorizationTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; + 7645C3630CAC8C86B8AA143F /* StaticLMSDirectoryService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StaticLMSDirectoryService.swift; path = TenantPicker/StaticLMSDirectoryService.swift; sourceTree = ""; }; + 76632219FE9EACAF3975E68F /* StaticLMSDirectoryServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = StaticLMSDirectoryServiceTests.swift; path = LMSDirectory/StaticLMSDirectoryServiceTests.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 = ""; }; 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 = ""; }; @@ -106,11 +126,16 @@ 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 = ""; }; + 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 +144,12 @@ 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 = ""; }; + 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 */ @@ -196,6 +224,7 @@ 025F40DE29D1C1350064C183 /* Reset Password */, 02F3BFE4292533720051930C /* AuthorizationRouter.swift */, 02A2ACDA2A4B016100FBBBBB /* AuthorizationAnalytics.swift */, + 40E2C04CC4389CAC09427A15 /* TenantPicker */, ); path = Presentation; sourceTree = ""; @@ -223,6 +252,7 @@ children = ( 022D0480297442B200E0059B /* Register */, 07169484296EC3D700E3DED6 /* Login */, + EF91968477D5715D1F0D4124 /* LMSDirectory */, ); path = Presentation; sourceTree = ""; @@ -232,6 +262,7 @@ children = ( 02E0618329DC2373006E9024 /* ResetPasswordViewModelTests.swift */, 07169463296D96DD00E3DED6 /* SignInViewModelTests.swift */, + D81BDDA5554F6699B56037FD /* LMSDirectory */, ); path = Login; sourceTree = ""; @@ -245,6 +276,7 @@ 0770DE3C28D0A319006D8A5D /* Products */, 0770DE4528D0A3DA006D8A5D /* Frameworks */, 83B4EF244208C770505E10CB /* Pods */, + 1AF6FF0E9FF856AB8F1E02CC /* TenantPicker */, ); sourceTree = ""; }; @@ -286,6 +318,31 @@ path = SwiftGen; sourceTree = ""; }; + 1AF6FF0E9FF856AB8F1E02CC /* TenantPicker */ = { + isa = PBXGroup; + children = ( + C54635D2863A11AFCC9A5235 /* LMSDirectoryFeature.swift */, + B39DF829F04AEA8C86DA599F /* LMSDirectoryLandingView.swift */, + E6268F99F8CE485FB7B908D8 /* LMSDirectoryView.swift */, + E45B6D76F9942E50F52B04B1 /* LMSDirectoryViewModel.swift */, + FD15916DB50A754388E1677F /* LMSSelectionCoordinator.swift */, + 643ED7945A47425D9B6E18D3 /* LMSDirectoryAnalytics.swift */, + B13A3D56EFC72C383B6B7809 /* LMSDirectoryService.swift */, + 2818570B2BF7697374ACD9ED /* LMSModels.swift */, + 6476DC515B100AE6F9D74AED /* LMSOverridesStore.swift */, + BC0922F56B70DDA9F2910C6C /* LMSThemeApplier.swift */, + ); + name = TenantPicker; + sourceTree = ""; + }; + 40E2C04CC4389CAC09427A15 /* TenantPicker */ = { + isa = PBXGroup; + children = ( + 7645C3630CAC8C86B8AA143F /* StaticLMSDirectoryService.swift */, + ); + name = TenantPicker; + sourceTree = ""; + }; 83B4EF244208C770505E10CB /* Pods */ = { isa = PBXGroup; children = ( @@ -359,6 +416,14 @@ path = SocialAuth; sourceTree = ""; }; + D81BDDA5554F6699B56037FD /* LMSDirectory */ = { + isa = PBXGroup; + children = ( + 9B9EF18C82B8CA9A89481E82 /* LMSDirectoryViewModelTests.swift */, + ); + name = LMSDirectory; + sourceTree = ""; + }; E03261622AE6464A002CA7EB /* Startup */ = { isa = PBXGroup; children = ( @@ -368,6 +433,15 @@ path = Startup; sourceTree = ""; }; + EF91968477D5715D1F0D4124 /* LMSDirectory */ = { + isa = PBXGroup; + children = ( + 76632219FE9EACAF3975E68F /* StaticLMSDirectoryServiceTests.swift */, + 4C6C779D00BF30C254684D3F /* StubURLProtocol.swift */, + ); + name = LMSDirectory; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -558,6 +632,9 @@ 07169464296D96DD00E3DED6 /* SignInViewModelTests.swift in Sources */, A5B468112F29C845002A4ECA /* AuthorizationMocks.generated.swift in Sources */, 02E0618429DC2373006E9024 /* ResetPasswordViewModelTests.swift in Sources */, + 935FEA69BE7D57F57D42BB86 /* LMSDirectoryViewModelTests.swift in Sources */, + E98677D626D2D111EAFFEEDB /* StaticLMSDirectoryServiceTests.swift in Sources */, + 0D40DAEE442BC84AC56E9E79 /* StubURLProtocol.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -589,6 +666,17 @@ CEB25A052CC13A36007FC792 /* SocialAuthResponse.swift in Sources */, CEB25A062CC13A36007FC792 /* MicrosoftAuthProvider.swift in Sources */, CEB25A072CC13A36007FC792 /* SocialAuthError.swift in Sources */, + E778A8126843DE891196033C /* LMSDirectoryFeature.swift in Sources */, + B9DC07B706D5509A68E70E00 /* LMSDirectoryLandingView.swift in Sources */, + 4F00E022EA4706DE1EBA0365 /* LMSDirectoryView.swift in Sources */, + 46E446F8EECA2F03A8ACBB22 /* LMSDirectoryViewModel.swift in Sources */, + F52BE36894F6BC06B9EF5C06 /* LMSSelectionCoordinator.swift in Sources */, + E834B83B7906F8A85301BAEA /* LMSDirectoryAnalytics.swift in Sources */, + 4B00D19EC0BDB0F0B4CF4B84 /* LMSDirectoryService.swift in Sources */, + 06FC79EABCA9B12D7E8669FA /* LMSModels.swift in Sources */, + 0FE969FB22F82F2D38C35356 /* LMSOverridesStore.swift in Sources */, + 95F57194827709F98991ECF9 /* LMSThemeApplier.swift in Sources */, + 314E58A9645028CD68A2B338 /* StaticLMSDirectoryService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index aeb19f3e4..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) @@ -49,9 +48,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) @@ -72,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( @@ -320,6 +318,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.isDirectoryReachable 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.loginBackground)) + .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/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/LMSDirectoryAnalytics.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift new file mode 100644 index 000000000..4abeb117c --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift @@ -0,0 +1,9 @@ +import Foundation + +protocol LMSDirectoryAnalytics: Sendable { + func selectionMade(id: String) +} + +struct LMSDirectoryAnalyticsNoop: LMSDirectoryAnalytics { + func selectionMade(id: String) {} +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift new file mode 100644 index 000000000..209448d78 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift @@ -0,0 +1,144 @@ +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 source: LMSDirectoryConfig.Source? + private nonisolated(unsafe) static var logoutObserver: NSObjectProtocol? + + private static let landingTitle = AuthLocalization.LmsDirectory.title + + public static func register(source: LMSDirectoryConfig.Source? = nil) { + guard !isRegistered else { return } + isRegistered = true + isEnabled = true + Self.source = source + 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( + 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)!, + coordinator: coordinator, + overridesStore: container.resolve(LMSOverridesStoreProtocol.self)!, + analytics: container.resolve(LMSDirectoryAnalytics.self)! + ) + } + + private static func registerDependencies() { + let container = Container.shared + + container.register(LMSOverridesStoreProtocol.self) { _ in + LMSOverridesStore() + }.inObjectScope(.container) + + container.register(LMSDirectoryAnalytics.self) { _ in + LMSDirectoryAnalyticsNoop() + }.inObjectScope(.container) + + container.register(LMSDirectoryService.self) { _ in + switch source { + case let .document(url): + return StaticLMSDirectoryService(source: .url(url)) + case let .bundledDocument(name): + return StaticLMSDirectoryService( + source: .bundledFile(name: name, bundle: .lmsDirectoryHost) + ) + case .none: + // The feature is only ever registered when the config names a + // source (see AppDelegate/RouteController/Router), so this is a + // programming error rather than a state a build can ship in. + fatalError("LMSDirectoryService requires a directory source.") + } + }.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() { + let overrides = Container.shared.resolve(LMSOverridesStoreProtocol.self) ?? LMSOverridesStore() + guard let selection = overrides.currentSelection() else { return } + + LMSThemeApplier.applyAccentColor(selection.accentColor, darkColor: selection.accentColorDark) + LMSThemeApplier.applyLoginBackground(LMSImageSource(url: selection.theme?.loginBackgroundURL)) + // 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 + clearPersistedSelection() + } + } + + /// 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 storage = Container.shared.resolve(CoreStorage.self) + try? overrides.clear(storage: storage) + LMSThemeApplier.applyAccentColor(nil) + LMSThemeApplier.applyLoginBackground(nil) + } + +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift new file mode 100644 index 000000000..c08652698 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift @@ -0,0 +1,26 @@ +// +// LMSDirectoryLandingView.swift +// Authorization +// +// The first screen of a multi-tenant build: which platform is this? +// + +import SwiftUI +import Theme + +struct LMSDirectoryLandingView: View { + @StateObject private var viewModel: LMSDirectoryViewModel + + init(viewModel: LMSDirectoryViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + ScrollView { + LMSDirectoryView(viewModel: viewModel) + .padding(24) + } + .background(Theme.Colors.background.ignoresSafeArea()) + .navigationTitle(AuthLocalization.LmsDirectory.title) + } +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift new file mode 100644 index 000000000..41fe5a787 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift @@ -0,0 +1,24 @@ +// +// LMSDirectoryService.swift +// Authorization +// +// Where the app's list of platforms comes from. One implementation today — +// a JSON document, hosted or shipped with the app — behind a protocol so the +// screen does not care which of the two it got. +// + +import Core +import Foundation + +enum LMSDirectoryError: Error { + case notFound + case offline + case decodingFailed +} + +protocol LMSDirectoryService: Sendable { + /// Every platform in the directory, in the order the document lists them. + func platforms() async throws -> [LMSSummary] + /// Everything needed to re-theme the app and sign in to one of them. + func details(id: String) async throws -> LMSDetail +} diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift new file mode 100644 index 000000000..21c9552ef --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift @@ -0,0 +1,177 @@ +// +// LMSDirectoryView.swift +// Authorization +// +// The list of platforms the directory holds. A document is a fixed list, so +// this is a list and nothing else — no search box, and nothing to type. +// + +import Core +import Kingfisher +import SwiftUI +import Theme + +struct LMSDirectoryView: View { + @ObservedObject private var viewModel: LMSDirectoryViewModel + + init(viewModel: LMSDirectoryViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if let provider = viewModel.providerName, !provider.isEmpty { + Text(AuthLocalization.LmsDirectory.providerSubtitle(provider)) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("lms_provider_name") + } + + switch viewModel.state { + case .loading: + loadingView + case .ready: + ForEach(viewModel.platforms) { platform in + row(platform) + } + case .empty: + message(AuthLocalization.LmsDirectory.empty) + case let .failed(text): + message(text) + Button(AuthLocalization.LmsDirectory.retry) { + viewModel.retry() + } + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + .accessibilityIdentifier("lms_retry_button") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func row(_ item: LMSSummary) -> some View { + Button(action: { viewModel.select(item) }) { + LMSRowContent( + title: item.title, + subtitle: item.shortDescription, + url: item.baseURL, + logoURL: item.logoURL, + accentColorHex: item.accentColorHex + ) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("lms_platform_\(item.id)") + } + + private var loadingView: some View { + HStack(spacing: 12) { + ProgressView() + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .accessibilityIdentifier("lms_loading") + } + + private func message(_ text: String) -> some View { + Text(text) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("lms_message") + } +} + +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) { + switch LMSImageSource(url: logoURL) { + case let .remote(url): + KFImage.url(url) + .placeholder { + lmsInitialsView + } + .onFailure { _ in } + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 44, height: 44) + .cornerRadius(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Theme.Colors.background) + ) + case .bundled: + // Shipped inside the app: there is nothing to load, so it draws in + // the first frame and works with no network at all. + if let image = LMSImageSource(url: logoURL)?.bundledImage() { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 44, height: 44) + .cornerRadius(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Theme.Colors.background) + ) + } else { + lmsInitialsView + } + case .none: + 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..653d5dbbd --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift @@ -0,0 +1,131 @@ +// +// LMSDirectoryViewModel.swift +// Authorization +// +// The platform picker: read the directory, show what is in it, and hand the +// chosen platform to the coordinator that re-themes the app and routes on to +// sign-in. +// + +import Core +import Foundation + +@MainActor +final class LMSDirectoryViewModel: ObservableObject { + + enum ViewState: Equatable { + case loading + case ready + case empty + case failed(String) + } + + @Published private(set) var platforms: [LMSSummary] = [] + @Published private(set) var providerName: String? + @Published private(set) var state: ViewState = .loading + + private let service: LMSDirectoryService + private let coordinator: LMSSelectionCoordinating + private let overridesStore: LMSOverridesStoreProtocol + private let analytics: LMSDirectoryAnalytics + + init( + service: LMSDirectoryService, + coordinator: LMSSelectionCoordinating, + overridesStore: LMSOverridesStoreProtocol, + analytics: LMSDirectoryAnalytics + ) { + self.service = service + self.coordinator = coordinator + self.overridesStore = overridesStore + self.analytics = analytics + applyPersistedTheme() + Task { await load() } + } + + func select(_ platform: LMSSummary) { + Task { await applyDetails(id: platform.id) } + } + + func retry() { + Task { await load() } + } + + // MARK: - Private + + private func load() async { + state = .loading + do { + let items = try await service.platforms() + platforms = items + state = items.isEmpty ? .empty : .ready + if let document = service as? StaticLMSDirectoryService { + providerName = try? await document.providerName() + } + await prefetchArtwork(for: items) + } catch { + state = .failed(AuthLocalization.LmsDirectory.loadFailed) + } + } + + /** + Pull the images the next screens will need into the shared cache. + + The whole directory arrives in one document, so every platform's sign-in + background is known while the learner is still choosing. Fetching them now + is the difference between a branded screen that draws in its first frame and + one that shows a placeholder first. + */ + private func prefetchArtwork(for items: [LMSSummary]) async { + var sources = items.compactMap { LMSImageSource(url: $0.logoURL) } + if let document = service as? StaticLMSDirectoryService, + let all = try? await document.imageSources() { + sources = all + } + LMSThemeApplier.prefetch(sources) + } + + private func applyDetails(id: String) async { + do { + let detail = try await service.details(id: id) + guard let payload = try? JSONEncoder().encode(detail.asDTO()) else { + state = .failed(AuthLocalization.LmsDirectory.invalidPlatform) + return + } + await coordinator.applySelection(detail: detail, payload: payload) + } catch { + state = .failed(AuthLocalization.LmsDirectory.selectFailed) + } + } + + /// Re-apply the branding of whatever was chosen last, so returning to this + /// screen does not flash the stock theme before a choice is made. + private func applyPersistedTheme() { + guard let selection = overridesStore.currentSelection() else { return } + LMSThemeApplier.applyAccentColor(selection.accentColor, darkColor: selection.accentColorDark) + LMSThemeApplier.applyLoginBackground(LMSImageSource(url: selection.theme?.loginBackgroundURL)) + } +} + +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/LMSModels.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift new file mode 100644 index 000000000..dbff89922 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift @@ -0,0 +1,194 @@ +import Foundation + +struct LMSSummary: 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? + + /// What a platform gets when it says nothing: every flag off. + /// A directory can be written by hand, and a hand-written entry should + /// not have to spell out flags it does not use. + static let none = FeatureFlags(preLoginDiscovery: false, unknownUnitsMode: nil) + + init(preLoginDiscovery: Bool, unknownUnitsMode: String?) { + self.preLoginDiscovery = preLoginDiscovery + self.unknownUnitsMode = unknownUnitsMode + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + preLoginDiscovery = try container.decodeIfPresent(Bool.self, forKey: .preLoginDiscovery) ?? false + unknownUnitsMode = try container.decodeIfPresent(String.self, forKey: .unknownUnitsMode) + } + + 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 + } +} + +// MARK: - Wire format + +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 ?? .none, + 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..edbeb3d9c --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift @@ -0,0 +1,68 @@ +import Core +import Foundation +import Swinject +import OEXFoundation +import Alamofire + +@MainActor +public protocol LMSSelectionRouting: AnyObject { + func presentDiscovery() + func showLogin() + /// Return to the LMS directory landing (the sign-in "Change" affordance). + func showLanding() +} + +@MainActor +protocol LMSSelectionCoordinating: Sendable { + func applySelection(detail: LMSDetail, payload: Data) async +} + +@MainActor +final class LMSSelectionCoordinator: LMSSelectionCoordinating { + private let overridesStore: LMSOverridesStoreProtocol + private let analytics: LMSDirectoryAnalytics + private weak var router: LMSSelectionRouting? + private let coreStorage: CoreStorage? + private let container: Container + + init( + overridesStore: LMSOverridesStoreProtocol, + analytics: LMSDirectoryAnalytics, + router: LMSSelectionRouting?, + coreStorage: CoreStorage?, + container: Container + ) { + self.overridesStore = overridesStore + self.analytics = analytics + self.router = router + self.coreStorage = coreStorage + self.container = container + } + + func applySelection(detail: LMSDetail, payload: Data) async { + do { + try overridesStore.save(detail: detail, payload: payload, storage: coreStorage) + analytics.selectionMade(id: detail.id) + LMSThemeApplier.applyAccentColor(detail.accentColor, darkColor: detail.accentColorDark) + LMSThemeApplier.applyLoginBackground(LMSImageSource(url: detail.theme?.loginBackgroundURL)) + 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..201d16cf9 --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift @@ -0,0 +1,175 @@ +import Core +import Kingfisher +import SwiftUI +import Theme +import UIKit + +enum LMSThemeApplier { + + /// Warm every image the directory will ask for, before any screen asks for it. + /// + /// The whole directory arrives in one document, so the sign-in background of a + /// platform is known long before the learner picks it. Fetching it now is what + /// removes the visible pop-in later: by the time the sign-in screen is built, + /// the image is already decoded in Kingfisher's cache. + static func prefetch(_ sources: [LMSImageSource]) { + let urls = sources.compactMap(\.remoteURL) + guard !urls.isEmpty else { return } + ImagePrefetcher(urls: urls).start() + } + + /// Put the selected platform's sign-in background where `LmsHeaderBackground` + /// can draw it in its first frame. + /// + /// Handing over a decoded image rather than a URL is the whole point: a view + /// that resolves a URL has to render something else first, and that flash is + /// what this removes. A bundled image is read straight from the app; a remote + /// one comes from Kingfisher's cache when it was prefetched, and is fetched + /// here when it was not. + static func applyLoginBackground(_ source: LMSImageSource?) { + guard let source else { + Theme.Images.update(headerBackground: nil) + return + } + if let bundled = source.bundledImage() { + Theme.Images.update(headerBackground: bundled) + return + } + guard let url = source.remoteURL else { + Theme.Images.update(headerBackground: nil) + return + } + KingfisherManager.shared.retrieveImage(with: url) { result in + let image = try? result.get().image + Task { @MainActor in + Theme.Images.update(headerBackground: image) + } + } + } + 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) + + 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, + deleteAccountBG: deleteAccountBackground, + resumeButtonBG: resumeBackground, + socialAuthColor: socialAuthColor, + slidingTextColor: slidingText, + slidingStrokeColor: slidingStroke + ) + + 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/StaticLMSDirectoryService.swift b/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift new file mode 100644 index 000000000..c455229bb --- /dev/null +++ b/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift @@ -0,0 +1,196 @@ +// +// StaticLMSDirectoryService.swift +// Authorization +// +// Reads the whole directory from a single JSON document instead of a live API. +// +// The document can come from a URL or from a file inside the app bundle, and +// nothing downstream can tell the difference. That is the point: an operator +// publishes the file wherever they like — their own web server, a CDN, or the +// app binary itself — and the app never learns anything about where it lives. +// +// Everything arrives at once, so the platform list and every platform's details +// are known before the learner taps anything. That is what makes it possible to +// warm the logos and sign-in backgrounds ahead of the screen that shows them. +// + +import Core +import Foundation + +/// Wire format of the directory document. `version` is the only field a future +/// change is allowed to key off; unknown keys are ignored, so a newer document +/// stays readable by an older build. +struct LMSDirectoryDocumentDTO: Codable { + struct Provider: Codable { + let name: String + let tagline: String? + let logoURL: URL? + + enum CodingKeys: String, CodingKey { + case name + case tagline + case logoURL = "logo_url" + } + } + + let version: Int + let provider: Provider? + let platforms: [LMSDetailDTO] +} + +/// Where a document is read from. +enum LMSDirectoryDocumentSource: Sendable, Equatable { + /// Fetched over the network, then kept for the lifetime of the process. + case url(URL) + /// Read from a JSON file inside the app bundle. Never touches the network. + case bundledFile(name: String, bundle: Bundle) + + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case let (.url(l), .url(r)): + return l == r + case let (.bundledFile(ln, _), .bundledFile(rn, _)): + return ln == rn + default: + return false + } + } +} + +final class StaticLMSDirectoryService: LMSDirectoryService { + + private let source: LMSDirectoryDocumentSource + private let session: URLSession + private let cache = DocumentCache() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + init(source: LMSDirectoryDocumentSource, session: URLSession = .shared) { + self.source = source + self.session = session + } + + // MARK: - LMSDirectoryService + + func platforms() async throws -> [LMSSummary] { + try await allPlatforms().map(\.summary) + } + + func details(id: String) async throws -> LMSDetail { + guard let match = try await allPlatforms().first(where: { $0.id == id }) else { + throw LMSDirectoryError.notFound + } + return match + } + + /// The name the document's publisher gave themselves, shown above the list. + func providerName() async throws -> String? { + try await document().provider?.name + } + + /// Every image the app will need, so a caller can warm them before they are shown. + func imageSources() async throws -> [LMSImageSource] { + try await allPlatforms().flatMap(\.imageSources) + } + + // MARK: - Private + + private func allPlatforms() async throws -> [LMSDetail] { + try await document().platforms.map(\.domainModel) + } + + private func document() async throws -> LMSDirectoryDocumentDTO { + if let cached = await cache.value { + return cached + } + let data = try await load() + do { + let decoded = try Self.decoder.decode(LMSDirectoryDocumentDTO.self, from: data) + await cache.store(decoded) + return decoded + } catch { + throw LMSDirectoryError.decodingFailed + } + } + + private func load() async throws -> Data { + switch source { + case let .bundledFile(name, bundle): + let base = (name as NSString).deletingPathExtension + let ext = (name as NSString).pathExtension + // The app target is where an operator would naturally drop the file, but + // a framework bundle is a reasonable place too, and which one they chose + // is not worth making them read documentation about. + for candidate in [bundle, .main] { + if let url = candidate.url(forResource: base, withExtension: ext.isEmpty ? "json" : ext), + let data = try? Data(contentsOf: url) { + return data + } + } + throw LMSDirectoryError.notFound + + case let .url(url): + do { + let (data, response) = try await session.data(from: url) + guard + let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) + else { + throw LMSDirectoryError.notFound + } + return data + } 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 + ] + + /// Holds the parsed document so the picker, the theming and the prefetch all + /// work from one copy rather than re-reading it three times. + private actor DocumentCache { + private(set) var value: LMSDirectoryDocumentDTO? + + func store(_ document: LMSDirectoryDocumentDTO) { + value = document + } + } +} + +private extension LMSDetail { + var summary: LMSSummary { + LMSSummary( + id: id, + title: title, + shortDescription: shortDescription, + baseURL: baseURL, + logoURL: effectiveLogoURL, + accentColorHex: accentColorHex + ) + } + + var imageSources: [LMSImageSource] { + [effectiveLogoURL, theme?.loginBackgroundURL] + .compactMap { $0 } + .compactMap(LMSImageSource.init(url:)) + } +} + +extension Bundle { + /// Where a bundled directory document is looked for first: the app itself, + /// because that is where whoever ships the build adds the file. + static var lmsDirectoryHost: Bundle { .main } +} diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index 99fddd18f..0dba92ad9 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -54,6 +54,28 @@ public enum AuthLocalization { /// Forgot password public static let title = AuthLocalization.tr("Localizable", "FORGOT.TITLE", fallback: "Forgot password") } + public enum LmsDirectory { + /// Change + public static let change = AuthLocalization.tr("Localizable", "LMS_DIRECTORY.CHANGE", fallback: "Change") + /// This directory lists no platforms yet. + public static let empty = AuthLocalization.tr("Localizable", "LMS_DIRECTORY.EMPTY", fallback: "This directory lists no platforms yet.") + /// That platform's settings could not be read. + public static let invalidPlatform = AuthLocalization.tr("Localizable", "LMS_DIRECTORY.INVALID_PLATFORM", fallback: "That platform's settings could not be read.") + /// We couldn't load the list of platforms. + public static let loadFailed = AuthLocalization.tr("Localizable", "LMS_DIRECTORY.LOAD_FAILED", fallback: "We couldn't load the list of platforms.") + /// Platforms published by %@ + public static func providerSubtitle(_ p1: Any) -> String { + return AuthLocalization.tr("Localizable", "LMS_DIRECTORY.PROVIDER_SUBTITLE", String(describing: p1), fallback: "Platforms published by %@") + } + /// Try again + public static let retry = AuthLocalization.tr("Localizable", "LMS_DIRECTORY.RETRY", fallback: "Try again") + /// We couldn't open that platform. Please try again. + public static let selectFailed = AuthLocalization.tr("Localizable", "LMS_DIRECTORY.SELECT_FAILED", fallback: "We couldn't open that platform. Please try again.") + /// Selected LMS + public static let selected = AuthLocalization.tr("Localizable", "LMS_DIRECTORY.SELECTED", fallback: "Selected LMS") + /// Choose your platform + public static let title = AuthLocalization.tr("Localizable", "LMS_DIRECTORY.TITLE", fallback: "Choose your platform") + } public enum SignIn { /// By signing in to this app, you agree to the [%@ End User License Agreement](%@) and [%@ Terms of Service and Honor Code](%@) and you acknowledge that %@ and each Member process your personal data in /// accordance with the [Privacy Policy.](%@) diff --git a/Authorization/Authorization/en.lproj/Localizable.strings b/Authorization/Authorization/en.lproj/Localizable.strings index 380bf41e6..4996726c9 100644 --- a/Authorization/Authorization/en.lproj/Localizable.strings +++ b/Authorization/Authorization/en.lproj/Localizable.strings @@ -55,3 +55,13 @@ accordance with the [Privacy Policy.](%@)"; "STARTUP.SEARCH_PLACEHOLDER" = "Search our 3000+ courses"; "STARTUP.EXPLORE_ALL_COURSES" = "Explore all courses"; "STARTUP.TITLE" = "Start"; + +"LMS_DIRECTORY.TITLE" = "Choose your platform"; +"LMS_DIRECTORY.PROVIDER_SUBTITLE" = "Platforms published by %@"; +"LMS_DIRECTORY.EMPTY" = "This directory lists no platforms yet."; +"LMS_DIRECTORY.LOAD_FAILED" = "We couldn't load the list of platforms."; +"LMS_DIRECTORY.SELECT_FAILED" = "We couldn't open that platform. Please try again."; +"LMS_DIRECTORY.INVALID_PLATFORM" = "That platform's settings could not be read."; +"LMS_DIRECTORY.RETRY" = "Try again"; +"LMS_DIRECTORY.SELECTED" = "Selected LMS"; +"LMS_DIRECTORY.CHANGE" = "Change"; 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/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift new file mode 100644 index 000000000..b80a27a3f --- /dev/null +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift @@ -0,0 +1,151 @@ +// +// LMSDirectoryViewModelTests.swift +// AuthorizationTests +// +// The platform picker reads a fixed list and hands one platform to the +// coordinator. These cover what the learner sees while that happens: the list, +// an empty directory, a document that could not be read, and the selection +// actually reaching the coordinator that re-themes the app. +// + +import XCTest +import Foundation +@testable import Core +@testable import Authorization + +@MainActor +final class LMSDirectoryViewModelTests: XCTestCase { + + private func makeViewModel(_ service: StubDirectoryService) -> (LMSDirectoryViewModel, StubCoordinator) { + let coordinator = StubCoordinator() + let viewModel = LMSDirectoryViewModel( + service: service, + coordinator: coordinator, + overridesStore: StubOverridesStore(), + analytics: LMSDirectoryAnalyticsNoop() + ) + return (viewModel, coordinator) + } + + private func settle() async { + try? await Task.sleep(nanoseconds: 200_000_000) + } + + func testTheDirectoryIsListedAsTheDocumentOrdersIt() async { + let service = StubDirectoryService() + service.platformsResult = .success([Self.sampleResult]) + let (viewModel, _) = makeViewModel(service) + + await settle() + + XCTAssertEqual(viewModel.state, .ready) + XCTAssertEqual(viewModel.platforms.map(\.id), [Self.sampleResult.id]) + } + + func testADocumentWithNoPlatformsSaysSoRatherThanLookingBroken() async { + let service = StubDirectoryService() + service.platformsResult = .success([]) + let (viewModel, _) = makeViewModel(service) + + await settle() + + XCTAssertEqual(viewModel.state, .empty) + } + + func testADocumentThatCannotBeReadSurfacesAsAFailure() async { + let service = StubDirectoryService() + service.platformsResult = .failure(LMSDirectoryError.decodingFailed) + let (viewModel, _) = makeViewModel(service) + + await settle() + + guard case .failed = viewModel.state else { + return XCTFail("expected a failure state, got \(viewModel.state)") + } + XCTAssertTrue(viewModel.platforms.isEmpty) + } + + func testChoosingAPlatformHandsItToTheCoordinator() async { + let service = StubDirectoryService() + service.platformsResult = .success([Self.sampleResult]) + service.detailsResult = .success(Self.sampleDetail(preLoginDiscovery: false)) + let (viewModel, coordinator) = makeViewModel(service) + await settle() + + viewModel.select(Self.sampleResult) + await settle() + + XCTAssertEqual(coordinator.appliedDetail?.id, Self.sampleResult.id) + } + + func testAPlatformThatCannotBeReadLeavesTheListUsable() async { + let service = StubDirectoryService() + service.platformsResult = .success([Self.sampleResult]) + service.detailsResult = .failure(LMSDirectoryError.notFound) + let (viewModel, coordinator) = makeViewModel(service) + await settle() + + viewModel.select(Self.sampleResult) + await settle() + + XCTAssertNil(coordinator.appliedDetail) + guard case .failed = viewModel.state else { + return XCTFail("expected a failure state, got \(viewModel.state)") + } + } + + private static var sampleResult: LMSSummary { + LMSSummary( + id: "5", + title: "Atentamente", + shortDescription: "Atentamente MX", + baseURL: URL(string: "https://educar.atentamente.mx")!, + logoURL: nil, + 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 + +private final class StubDirectoryService: LMSDirectoryService, @unchecked Sendable { + var platformsResult: Result<[LMSSummary], Error> = .success([]) + var detailsResult: Result = .failure(LMSDirectoryError.notFound) + + func platforms() async throws -> [LMSSummary] { try platformsResult.get() } + func details(id: String) async throws -> LMSDetail { try detailsResult.get() } +} + +private final class StubCoordinator: LMSSelectionCoordinating, @unchecked Sendable { + private(set) var appliedDetail: LMSDetail? + func applySelection(detail: LMSDetail, payload: Data) async { + appliedDetail = detail + } +} + +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/StaticLMSDirectoryServiceTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift new file mode 100644 index 000000000..44a154682 --- /dev/null +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift @@ -0,0 +1,214 @@ +// +// StaticLMSDirectoryServiceTests.swift +// AuthorizationTests +// +// A directory read from a single JSON document — hosted or shipped inside the +// app — has to behave exactly like one read from a live service, and it has to +// keep behaving that way with no network at all. That is the whole promise of +// the document, so these are the tests that hold it. +// + +import Core +import XCTest +@testable import Authorization + +final class StaticLMSDirectoryServiceTests: XCTestCase { + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + return URLSession(configuration: configuration) + } + + override func tearDown() { + StubURLProtocol.handler = nil + super.tearDown() + } + + private static let document = """ + { + "version": 1, + "provider": { "name": "Northwind", "tagline": "Five campuses, one app", "logo_url": null }, + "platforms": [ + { + "id": "1", + "title": "Alpha", + "description": "Alpha campus", + "short_description": "Alpha", + "base_url": "https://alpha.example.edu", + "logo_url": "https://cdn.example.com/alpha.png", + "accent_color": "#112233", + "api": { + "host_url": "https://alpha.example.edu", + "feedback_email": "support@example.edu", + "oauth_client_id": "alpha-client" + }, + "feature_flags": { "pre_login_discovery": true, "unknown_units_mode": "block" }, + "theme": { "login_background_url": "alpha-bg.png", "accent_color_dark": "#445566" } + }, + { + "id": "2", + "title": "Beta", + "description": "Beta campus", + "short_description": "Beta", + "base_url": "https://beta.example.edu", + "logo_url": "beta-logo.png", + "accent_color": null, + "api": { + "host_url": "https://beta.example.edu", + "feedback_email": "", + "oauth_client_id": "beta-client" + }, + "feature_flags": { "pre_login_discovery": false, "unknown_units_mode": null } + } + ] + } + """ + + private func makeRemoteService(body: String? = nil, status: Int = 200) -> StaticLMSDirectoryService { + let payload = body ?? Self.document + StubURLProtocol.handler = { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: nil, + headerFields: nil + )! + return (response, Data(payload.utf8)) + } + return StaticLMSDirectoryService( + source: .url(URL(string: "https://example.com/directory.json")!), + session: makeSession() + ) + } + + // MARK: - Reading the document + + func testFeaturedReturnsEveryPlatformInDocumentOrder() async throws { + let items = try await makeRemoteService().platforms() + XCTAssertEqual(items.map(\.title), ["Alpha", "Beta"]) + } + + func testDetailsComeFromTheSameDocumentWithoutAnotherRequest() async throws { + let service = makeRemoteService() + _ = try await service.platforms() + // The document is fetched once and kept; if this asked the network again it + // would fail, because the stub is torn down below. + StubURLProtocol.handler = nil + let detail = try await service.details(id: "2") + XCTAssertEqual(detail.title, "Beta") + XCTAssertEqual(detail.api.oauthClientId, "beta-client") + } + + func testUnknownIdIsNotFound() async throws { + let service = makeRemoteService() + do { + _ = try await service.details(id: "does-not-exist") + XCTFail("Expected notFound") + } catch { + XCTAssertEqual(error as? LMSDirectoryError, .notFound) + } + } + + // MARK: - The mode a document implies + + func testTheProviderNameComesFromTheDocument() async throws { + let name = try await makeRemoteService().providerName() + + XCTAssertEqual(name, "Northwind") + } + + // MARK: - Failures + + /// The smallest document a person could reasonably write by hand. Anything + /// the apps can default, they must default — the two platforms have to + /// accept the same file, and Android's parser already does. + func testAMinimalHandWrittenDocumentIsAccepted() async throws { + let minimal = """ + { + "version": 1, + "platforms": [ + { + "id": "1", + "title": "Alpha", + "description": "Alpha campus", + "short_description": "Alpha", + "base_url": "https://alpha.example.edu", + "api": { + "host_url": "https://alpha.example.edu", + "feedback_email": "support@example.edu", + "oauth_client_id": "alpha-client" + } + } + ] + } + """ + let detail = try await makeRemoteService(body: minimal).details(id: "1") + + XCTAssertEqual(detail.title, "Alpha") + XCTAssertFalse(detail.featureFlags.preLoginDiscovery) + XCTAssertNil(detail.featureFlags.unknownUnitsMode) + XCTAssertNil(detail.logoURL) + } + + func testMalformedDocumentReportsDecodingFailure() async { + let service = makeRemoteService(body: "{\"version\": 1}") + do { + _ = try await service.platforms() + XCTFail("Expected decodingFailed") + } catch { + XCTAssertEqual(error as? LMSDirectoryError, .decodingFailed) + } + } + + func testHTTPErrorIsNotFound() async { + let service = makeRemoteService(status: 500) + do { + _ = try await service.platforms() + XCTFail("Expected notFound") + } catch { + XCTAssertEqual(error as? LMSDirectoryError, .notFound) + } + } + + func testALostConnectionSurfacesAsOffline() async { + StubURLProtocol.handler = { _ in + throw URLError(.notConnectedToInternet) + } + let service = StaticLMSDirectoryService( + source: .url(URL(string: "https://example.com/directory.json")!), + session: makeSession() + ) + do { + _ = try await service.platforms() + XCTFail("Expected offline") + } catch { + XCTAssertEqual(error as? LMSDirectoryError, .offline) + } + } + + func testAMissingBundledFileIsNotFound() async { + let service = StaticLMSDirectoryService( + source: .bundledFile(name: "no-such-directory.json", bundle: .main) + ) + do { + _ = try await service.platforms() + XCTFail("Expected notFound") + } catch { + XCTAssertEqual(error as? LMSDirectoryError, .notFound) + } + } + + // MARK: - Images the app will need + + func testImageSourcesSplitRemoteAddressesFromBundledNames() async throws { + let sources = try await makeRemoteService().imageSources() + // Alpha: a remote logo and a bundled background. Beta: a bundled logo and + // no background at all. This is the mixture an operator ends up with when + // they bundle some artwork and leave the rest hosted. + XCTAssertTrue(sources.contains(.remote(URL(string: "https://cdn.example.com/alpha.png")!))) + XCTAssertTrue(sources.contains(.bundled(name: "alpha-bg.png"))) + XCTAssertTrue(sources.contains(.bundled(name: "beta-logo.png"))) + XCTAssertEqual(sources.count, 3) + } +} diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StubURLProtocol.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StubURLProtocol.swift new file mode 100644 index 000000000..a42b2e999 --- /dev/null +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StubURLProtocol.swift @@ -0,0 +1,33 @@ +// +// StubURLProtocol.swift +// AuthorizationTests +// +// Answers the directory document without a network, so the tests around it can +// say exactly what came back — including nothing, and a connection that dropped. +// + +import Foundation + +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() {} +} diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 150c7377c..2f0cc07ba 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -129,13 +129,17 @@ 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 */; }; + 315EFA6E6C806ECD7A37D51B /* LMSImageSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCAF519C51B689EAEEACA1C /* LMSImageSourceTests.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 */; }; + 82747331067960155ACCEC12 /* LMSDirectoryConfigSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AD428CA5F0C0F77A330E72 /* LMSDirectoryConfigSourceTests.swift */; }; 9784D47E2BF7762800AFEFFF /* FullScreenErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; 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 */; }; @@ -165,6 +169,7 @@ CED42FFC2D099F0400C7AD89 /* DownloadManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED42FFB2D099F0400C7AD89 /* DownloadManagerMock.swift */; }; CEF4AA1E2DBA41C4006C4F0A /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEF4AA1D2DBA41C4006C4F0A /* StringExtension.swift */; }; CFC84952299F8B890055E497 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC84951299F8B890055E497 /* Debounce.swift */; }; + DAA69AFA6DCA62D32DD92F01 /* LMSImageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 306EDAF293CF0A5139312D83 /* LMSImageSource.swift */; }; DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */; }; DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */; }; DBF6F24A2B0380E00098414B /* FeaturesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2492B0380E00098414B /* FeaturesConfig.swift */; }; @@ -289,6 +294,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 = ""; }; @@ -336,10 +342,13 @@ 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreAnalytics.swift; sourceTree = ""; }; 1A154A95AF4EE85A4A1C083B /* Pods-App-Core.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releasedev.xcconfig"; sourceTree = ""; }; 2B7E6FE7843FC4CF2BFA712D /* Pods-App-Core.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debug.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debug.xcconfig"; sourceTree = ""; }; + 306EDAF293CF0A5139312D83 /* LMSImageSource.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LMSImageSource.swift; sourceTree = ""; }; 33FA09A20AAE2B2A0BA89190 /* Pods_App_Core_CoreTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Core_CoreTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B74C6685E416657F3C5F5A8 /* Pods-App-Core.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releaseprod.xcconfig"; sourceTree = ""; }; + 3BCAF519C51B689EAEEACA1C /* LMSImageSourceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LMSImageSourceTests.swift; sourceTree = ""; }; 3C63D5D2247C793C259341B8 /* Pods-App-Core-CoreTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releasedev.xcconfig"; sourceTree = ""; }; + 43AD428CA5F0C0F77A330E72 /* LMSDirectoryConfigSourceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LMSDirectoryConfigSourceTests.swift; sourceTree = ""; }; 5CEFA8766C44C519B86C681D /* Pods-App-Core-CoreTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.debug.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.debug.xcconfig"; sourceTree = ""; }; 60153262DBC2F9E660D7E11B /* Pods-App-Core.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.release.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.release.xcconfig"; sourceTree = ""; }; 8F3B171E9FA5E6F40B4890A8 /* Pods-App-Core-CoreTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.debugstage.xcconfig"; sourceTree = ""; }; @@ -393,6 +402,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 */ @@ -577,6 +587,7 @@ 0770DE1828D0847D006D8A5D /* BaseRouter.swift */, 0231CDBD2922422D00032416 /* CSSInjector.swift */, 02280F5A294B4E6F0032823A /* Connectivity.swift */, + 306EDAF293CF0A5139312D83 /* LMSImageSource.swift */, ); path = Configuration; sourceTree = ""; @@ -903,6 +914,7 @@ A53A32342B233DEC005FE38A /* ThemeConfig.swift */, E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */, CE38BBD62D9D8294002CD276 /* ExperimentalFeaturesConfig.swift */, + F9AF280F7F4D9CFF6F315906 /* LMSDirectoryConfig.swift */, ); path = Config; sourceTree = ""; @@ -922,6 +934,9 @@ children = ( E09179FC2B0F204D002AB695 /* ConfigTests.swift */, BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */, + 05340946BE268A7D4BE1FD69 /* ConfigLMSDirectoryTests.swift */, + 3BCAF519C51B689EAEEACA1C /* LMSImageSourceTests.swift */, + 43AD428CA5F0C0F77A330E72 /* LMSDirectoryConfigSourceTests.swift */, ); path = Configuration; sourceTree = ""; @@ -1154,6 +1169,9 @@ CE953A3B2CD0DA940023D667 /* CoreMocks.generated.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */, + 3AA0380C24561FC1FC4E42AA /* ConfigLMSDirectoryTests.swift in Sources */, + 315EFA6E6C806ECD7A37D51B /* LMSImageSourceTests.swift in Sources */, + 82747331067960155ACCEC12 /* LMSDirectoryConfigSourceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1311,6 +1329,8 @@ 0254D1912BCD699F000CDE89 /* RefreshProgressView.swift in Sources */, CED42FFC2D099F0400C7AD89 /* DownloadManagerMock.swift in Sources */, 02066B482906F73400F4307E /* PickerMenu.swift in Sources */, + B8AF4F47FCE96744B4AD9F7E /* LMSDirectoryConfig.swift in Sources */, + DAA69AFA6DCA62D32DD92F01 /* LMSImageSource.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 8d0c4b9be..f1744d333 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.isDirectoryReachable, + 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.") @@ -127,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.") @@ -150,6 +167,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.isDirectoryReachable, + 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.") } @@ -164,6 +188,11 @@ extension Config: ConfigProtocol { } public var feedbackEmail: String { + if lmsDirectory.isDirectoryReachable, + let override = UserDefaults.standard.string(forKey: "lmsDirectory.selected_feedback_email"), + !override.isEmpty { + return override + } return string(for: ConfigKeys.feedbackEmailAddress.rawValue) ?? "" } diff --git a/Core/Core/Configuration/Config/LMSDirectoryConfig.swift b/Core/Core/Configuration/Config/LMSDirectoryConfig.swift new file mode 100644 index 000000000..25917139c --- /dev/null +++ b/Core/Core/Configuration/Config/LMSDirectoryConfig.swift @@ -0,0 +1,75 @@ +// +// LMSDirectoryConfig.swift +// Core +// +// Feature flag for the multi-tenant LMS Directory: a build that lets a learner +// choose which Open edX platform to sign in to. With ENABLED 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 directoryFile = "DIRECTORY_FILE" +} + +public class LMSDirectoryConfig: NSObject { + /// Master gate. When false the feature is completely inert. + public var enabled: Bool + /// Address of a JSON document listing the platforms this build offers. + public var directoryURL: String + /// The same document, shipped inside the app, e.g. "lms_directory.json". + /// Set this and the app never asks the network for its platform list. + public var directoryFile: String + + /// Whether the app has somewhere to read its platforms from. + public var isDirectoryReachable: Bool { + enabled && (!trimmedFile.isEmpty || !trimmedURL.isEmpty) + } + + /// Where the document comes from. + /// + /// A bundled file wins over a URL: a build that ships its own copy has + /// deliberately opted out of the network, and silently preferring a remote + /// list would undo that. + public enum Source: Equatable { + /// Fetched once, from anywhere the publisher chose to put it. + case document(URL) + /// Read from the app bundle. Never touches the network. + case bundledDocument(String) + } + + public var source: Source? { + guard enabled else { return nil } + if !trimmedFile.isEmpty { + return .bundledDocument(trimmedFile) + } + guard !trimmedURL.isEmpty, let url = URL(string: trimmedURL) else { return nil } + return .document(url) + } + + private var trimmedURL: String { + directoryURL.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var trimmedFile: String { + directoryFile.trimmingCharacters(in: .whitespacesAndNewlines) + } + + init(dictionary: [String: Any]) { + enabled = dictionary[Keys.enabled] as? Bool ?? false + directoryURL = dictionary[Keys.directoryURL] as? String ?? "" + directoryFile = dictionary[Keys.directoryFile] 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/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index 425443112..eb2ba168b 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/Configuration/LMSImageSource.swift b/Core/Core/Configuration/LMSImageSource.swift new file mode 100644 index 000000000..5f1ac478f --- /dev/null +++ b/Core/Core/Configuration/LMSImageSource.swift @@ -0,0 +1,76 @@ +// +// LMSImageSource.swift +// Core +// +// Where a directory image actually comes from. +// +// The directory document carries image fields as plain strings. A string that +// looks like a web address is fetched; anything else is the name of a file +// shipped inside the app. That one rule is what lets the same document work for +// an operator who hosts their images and for one who bundles them, without a +// second set of fields to keep in step. +// + +import Foundation +import UIKit + +public enum LMSImageSource: Sendable, Hashable { + /// An http(s) address to download. + case remote(URL) + /// A file inside the app bundle, e.g. "acme-logo.png" added to the app target. + case bundled(name: String) + + /// Classify a value taken from the directory document. + /// + /// `URL` accepts "acme-logo.png" quite happily and hands back a URL with no + /// scheme, so the scheme is what separates the two cases — not whether the + /// value parsed. + public init?(url: URL?) { + guard let url else { return nil } + self.init(value: url.absoluteString) + } + + public init?(value: String?) { + guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let scheme = URL(string: trimmed)?.scheme?.lowercased() + if scheme == "http" || scheme == "https", let url = URL(string: trimmed) { + self = .remote(url) + } else { + self = .bundled(name: trimmed) + } + } + + /// The address to download, or nil when the image is already on the device. + public var remoteURL: URL? { + if case let .remote(url) = self { return url } + return nil + } + + /// The bundled image, looked up in `bundle` and then in the main bundle. + /// + /// Both are tried because assets added to the app target and assets living in + /// a framework's own bundle are equally reasonable places for an operator to + /// have put them, and which one they picked is not worth documenting. + public func bundledImage(in bundle: Bundle = .main) -> UIImage? { + guard case let .bundled(name) = self else { return nil } + if let image = UIImage(named: name, in: bundle, compatibleWith: nil) { + return image + } + if bundle != .main, let image = UIImage(named: name, in: .main, compatibleWith: nil) { + return image + } + let stem = (name as NSString).deletingPathExtension + let ext = (name as NSString).pathExtension + for candidate in [bundle, .main] { + if let url = candidate.url(forResource: stem, withExtension: ext.isEmpty ? nil : ext), + let data = try? Data(contentsOf: url), + let image = UIImage(data: data) { + return image + } + } + return nil + } +} 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/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 new file mode 100644 index 000000000..fb566f4a1 --- /dev/null +++ b/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift @@ -0,0 +1,84 @@ +// +// 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, 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, "DIRECTORY_URL": directoryURL] + ]) + } + + 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") + } + + // 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/Core/CoreTests/Configuration/LMSDirectoryConfigSourceTests.swift b/Core/CoreTests/Configuration/LMSDirectoryConfigSourceTests.swift new file mode 100644 index 000000000..37d9fd898 --- /dev/null +++ b/Core/CoreTests/Configuration/LMSDirectoryConfigSourceTests.swift @@ -0,0 +1,59 @@ +// +// LMSDirectoryConfigSourceTests.swift +// CoreTests +// +// Which document a build reads is decided entirely by the config file, and the +// mistakes are invisible until someone ships: a build that quietly ignores the +// copy it bundled, or one that thinks it has a directory when it has nothing. +// + +import XCTest +@testable import Core + +final class LMSDirectoryConfigSourceTests: XCTestCase { + + private func config(_ dict: [String: Any]) -> LMSDirectoryConfig { + LMSDirectoryConfig(dictionary: dict) + } + + func testAnAddressIsFetchedAsADocument() { + XCTAssertEqual( + config(["ENABLED": true, "DIRECTORY_URL": "https://example.com/lms_directory.json"]).source, + .document(URL(string: "https://example.com/lms_directory.json")!) + ) + // Nothing hangs off the file extension: whatever the address ends in, what + // comes back is expected to be the document. + XCTAssertEqual( + config(["ENABLED": true, "DIRECTORY_URL": "https://example.com/directory"]).source, + .document(URL(string: "https://example.com/directory")!) + ) + } + + func testABundledFileWinsOverAnAddress() { + // A build shipping its own copy has opted out of the network; quietly + // preferring a remote list would undo that. + XCTAssertEqual( + config([ + "ENABLED": true, + "DIRECTORY_URL": "https://example.com/lms_directory.json", + "DIRECTORY_FILE": "lms_directory.json", + ]).source, + .bundledDocument("lms_directory.json") + ) + } + + func testNothingConfiguredMeansNoSourceAndNothingReachable() { + XCTAssertNil(config(["ENABLED": true]).source) + XCTAssertFalse(config(["ENABLED": true]).isDirectoryReachable) + XCTAssertNil(config(["ENABLED": false, "DIRECTORY_URL": "https://example.com/d.json"]).source) + } + + func testABundledFileAloneIsEnoughToBeReachable() { + XCTAssertTrue(config(["ENABLED": true, "DIRECTORY_FILE": "lms_directory.json"]).isDirectoryReachable) + } + + func testWhitespaceIsNotAConfiguredSource() { + XCTAssertNil(config(["ENABLED": true, "DIRECTORY_URL": " ", "DIRECTORY_FILE": " "]).source) + XCTAssertFalse(config(["ENABLED": true, "DIRECTORY_URL": " "]).isDirectoryReachable) + } +} diff --git a/Core/CoreTests/Configuration/LMSImageSourceTests.swift b/Core/CoreTests/Configuration/LMSImageSourceTests.swift new file mode 100644 index 000000000..03a245f37 --- /dev/null +++ b/Core/CoreTests/Configuration/LMSImageSourceTests.swift @@ -0,0 +1,55 @@ +// +// LMSImageSourceTests.swift +// CoreTests +// +// One field decides whether an image is downloaded or read out of the app. The +// rule is simple enough to state in a sentence, which is exactly why it needs +// tests: an operator editing the document by hand will lean on it. +// + +import XCTest +@testable import Core + +final class LMSImageSourceTests: XCTestCase { + + func testWebAddressesAreDownloaded() { + XCTAssertEqual( + LMSImageSource(value: "https://cdn.example.com/logo.png"), + .remote(URL(string: "https://cdn.example.com/logo.png")!) + ) + XCTAssertEqual( + LMSImageSource(value: "http://cdn.example.com/logo.png"), + .remote(URL(string: "http://cdn.example.com/logo.png")!) + ) + } + + func testAnythingElseIsAFileInsideTheApp() { + XCTAssertEqual(LMSImageSource(value: "acme-logo.png"), .bundled(name: "acme-logo.png")) + XCTAssertEqual(LMSImageSource(value: "logos/acme.webp"), .bundled(name: "logos/acme.webp")) + // A scheme we cannot fetch is not a download either. + XCTAssertEqual(LMSImageSource(value: "file:///tmp/a.png"), .bundled(name: "file:///tmp/a.png")) + } + + func testSurroundingWhitespaceDoesNotChangeTheAnswer() { + // Hand-edited JSON picks up stray spaces; a trailing one used to turn a + // perfectly good address into a request for "…png%20". + XCTAssertEqual( + LMSImageSource(value: " https://cdn.example.com/logo.png "), + .remote(URL(string: "https://cdn.example.com/logo.png")!) + ) + XCTAssertEqual(LMSImageSource(value: " acme.png "), .bundled(name: "acme.png")) + } + + func testEmptyAndMissingValuesProduceNothing() { + XCTAssertNil(LMSImageSource(value: nil)) + XCTAssertNil(LMSImageSource(value: "")) + XCTAssertNil(LMSImageSource(value: " ")) + XCTAssertNil(LMSImageSource(url: nil)) + } + + func testRemoteURLIsOnlyExposedForSomethingFetchable() { + XCTAssertNotNil(LMSImageSource(value: "https://cdn.example.com/a.png")?.remoteURL) + XCTAssertNil(LMSImageSource(value: "a.png")?.remoteURL) + XCTAssertNil(LMSImageSource(value: "https://cdn.example.com/a.png")?.bundledImage()) + } +} 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/Documentation/LMS_DIRECTORY.md b/Documentation/LMS_DIRECTORY.md new file mode 100644 index 000000000..0cffceb2b --- /dev/null +++ b/Documentation/LMS_DIRECTORY.md @@ -0,0 +1,140 @@ +# The LMS Directory + +A build of this app normally talks to one Open edX site, named in +`config.yaml`. With the LMS Directory on, it instead shows a list of platforms, +lets the learner pick one, re-themes to it and signs in against it. + +Off by default. With `ENABLED: false` nothing in this document applies and the +app behaves exactly as it always has. + +## Where the list comes from + +Two ways, and the config decides which: + +```yaml +LMS_DIRECTORY: + ENABLED: true + DIRECTORY_URL: "https://example.com/lms_directory.json" # a document, on the web + DIRECTORY_FILE: "" +``` + +```yaml +LMS_DIRECTORY: + ENABLED: true + DIRECTORY_URL: "" + DIRECTORY_FILE: "lms_directory.json" # a document, in the app +``` + +`DIRECTORY_URL` is fetched once, and whatever comes back is the document — the +address can be anything you can serve a file from. If both `DIRECTORY_URL` and +`DIRECTORY_FILE` are set the file wins: a build that ships its own copy has +deliberately opted out of the network, and quietly preferring a remote list would +undo that. + +## What a document looks like + +One JSON file. This is the whole format: + +```json +{ + "version": 1, + "provider": { + "name": "Northwind Education Group", + "tagline": "Five campuses, one app", + "logo_url": null + }, + "platforms": [ + { + "id": "1", + "title": "Northwind College", + "description": "The main campus, offering undergraduate programmes.", + "short_description": "Main campus", + "base_url": "https://learn.northwind.edu", + "logo_url": "https://cdn.northwind.edu/logo.png", + "accent_color": "#002545", + "visibility": "public", + "featured": false, + "api": { + "host_url": "https://learn.northwind.edu", + "feedback_email": "support@northwind.edu", + "oauth_client_id": "PASTE_THE_MOBILE_OAUTH_CLIENT_ID" + }, + "feature_flags": { + "pre_login_discovery": false, + "unknown_units_mode": "webview" + }, + "theme": { + "accent_color_dark": "#4989bf", + "login_background_url": "https://cdn.northwind.edu/signin.png", + "logo_upload_url": null + }, + "ui_components": { + "course_unit_progress_enabled": true, + "course_dropdown_navigation_enabled": true, + "pre_login_experience_enabled": false + }, + "dashboard": { "type": "list" } + } + ] +} +``` + +### Required + +| field | what it is | +| --- | --- | +| `version` | `1`. The only version there is. | +| `platforms[]` | At least one. An empty list gives the learner nothing to pick. | +| `id` | Unique within the file. A string, even when it looks like a number. | +| `title` | Shown in the list and on the sign-in screen. | +| `description` / `short_description` | Long and one-line blurbs. | +| `base_url` | The Open edX site. Must be `https` in a shipped build. | +| `api.host_url` | Usually the same as `base_url`. | +| `api.oauth_client_id` | The site's **mobile** OAuth client id. Sign-in fails without the right one. | +| `api.feedback_email` | May be `""`. | + +### Optional + +Everything else. Omit a key and the app uses its own default, so the smallest +useful entry is `id`, `title`, `description`, `short_description`, `base_url` +and `api`. `provider` is optional too; its `name` is shown above the list. + +`visibility` and `featured` are accepted and ignored — every platform in the +file is shown, in the order the file lists them. + +## Images + +Every image field takes either of two things, and the value itself says which: + +- something starting with `http://` or `https://` is downloaded; +- anything else is the **name of a file shipped with the app**. + +So `"logo_url": "https://cdn.northwind.edu/logo.png"` is fetched, and +`"logo_url": "northwind-logo.png"` is looked up in the app bundle. That is what makes a +fully offline build possible: put the images next to the document, refer to them +by name, and the app never asks the network for a picture. + +## Shipping the document inside the app + +1. Drag the document and its images into the Xcode project. +2. Tick **Copy items if needed** and your app target. +3. Check they appear under **Build Phases → Copy Bundle Resources**. + +Then set `DIRECTORY_FILE` to the file name and leave `DIRECTORY_URL` empty. The +app now works on a device that has never been online. + +## Where to get a document + +**Write it by hand.** For a handful of platforms this is the honest answer — +it is one JSON file, and the example above is a working template. + +**Or edit it somewhere.** Any tool that emits the shape above will do, and one +that exists today is : a form for adding +platforms and uploading their logos and sign-in artwork, which publishes the +document at a URL and also exports a `.zip` of the document with its image +fields already rewritten to file names, plus the images themselves — the bundle +the offline case needs. + +That is somebody's **unofficial** tool. It is not part of Open edX, not +maintained by this project, and nothing here depends on it. The app reads a +document; where the document came from is not its business. 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..430fda4ca 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 */; }; @@ -69,10 +70,21 @@ CEBA52772CEBB69100619E2B /* OEXFirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = CEBA52762CEBB69100619E2B /* OEXFirebaseAnalytics */; }; CED29EAD2D91D88E00836226 /* DatesPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED29EAC2D91D88E00836226 /* DatesPersistence.swift */; }; CEE5EDEE2D6E0A290089F67C /* DownloadsPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE5EDED2D6E0A290089F67C /* DownloadsPersistence.swift */; }; + D75823CC84EEDC2F392C55BA /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 114F73675C4FCAAC10A9DC8D /* Foundation.framework */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + D3FA983EEC00E7582BD83468 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 07D5DA2928D075AA00752FD9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 07D5DA3028D075AA00752FD9; + remoteInfo = OpenEdX; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 0770DE1528D07845006D8A5D /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -123,6 +135,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; }; @@ -138,10 +151,12 @@ 07D5DA3128D075AA00752FD9 /* OpenEdX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenEdX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 114F73675C4FCAAC10A9DC8D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 149FF39D2B9F1AB50034B33F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; 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; }; + 5D7F6A9EFC819522CAF51527 /* LMSDirectoryUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LMSDirectoryUITests.xctest; 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 = ""; }; @@ -242,6 +257,7 @@ 07D5DA3228D075AA00752FD9 /* Products */, 55A895025FB07897BA68E063 /* Pods */, 4E6FB43543890E90BB88D64D /* Frameworks */, + 47517A26C1DD1ADF1202C782 /* LMSDirectoryUITests */, ); sourceTree = ""; }; @@ -249,6 +265,7 @@ isa = PBXGroup; children = ( 07D5DA3128D075AA00752FD9 /* OpenEdX.app */, + 5D7F6A9EFC819522CAF51527 /* LMSDirectoryUITests.xctest */, ); name = Products; sourceTree = ""; @@ -270,10 +287,27 @@ 02ED50DA29A66007008341CD /* languages.json */, 02ED50D629A6554E008341CD /* сountries.json */, 0770DE6628D0BCC7006D8A5D /* Localizable.strings */, + 070574B3ABFF71F1AF727DDE /* LMSDirectoryRouter.swift */, ); path = OpenEdX; sourceTree = ""; }; + 25C7375E4F0D85B355173FE1 /* iOS */ = { + isa = PBXGroup; + children = ( + 114F73675C4FCAAC10A9DC8D /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + 47517A26C1DD1ADF1202C782 /* LMSDirectoryUITests */ = { + isa = PBXGroup; + children = ( + ); + name = LMSDirectoryUITests; + path = LMSDirectoryUITests; + sourceTree = ""; + }; 4E6FB43543890E90BB88D64D /* Frameworks */ = { isa = PBXGroup; children = ( @@ -292,6 +326,7 @@ 0770DE4A28D0A462006D8A5D /* Authorization.framework */, 0770DE1228D07845006D8A5D /* Core.framework */, 58FAA9E3ECC93D0E638D877D /* Pods_App_OpenEdX.framework */, + 25C7375E4F0D85B355173FE1 /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -599,6 +634,7 @@ A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, 022213D22C0E08E500B917E6 /* ProfilePersistence.swift in Sources */, A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, + 222D6F5B8D6BBD02F3E0AAA7 /* LMSDirectoryRouter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -642,6 +678,23 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 015D0D6F29C18604249DF169 /* ReleaseProd */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.LMSDirectoryUITests; + PRODUCT_NAME = LMSDirectoryUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = OpenEdX; + VALIDATE_PRODUCT = YES; + }; + name = ReleaseProd; + }; 02DD1C9529E80CC200F35DCE /* DebugStage */ = { isa = XCBuildConfiguration; buildSettings = { @@ -901,7 +954,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; @@ -993,7 +1046,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; @@ -1212,6 +1265,121 @@ }; name = ReleaseProd; }; + 486F5B1C680B5D2E10D4174B /* ReleaseStage */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.LMSDirectoryUITests; + PRODUCT_NAME = LMSDirectoryUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = OpenEdX; + VALIDATE_PRODUCT = YES; + }; + name = ReleaseStage; + }; + 4F4E9E181B429D5E482AD15E /* DebugStage */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.LMSDirectoryUITests; + PRODUCT_NAME = LMSDirectoryUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = OpenEdX; + }; + name = DebugStage; + }; + B53ADC2BD64591E30664E42F /* ReleaseDev */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.LMSDirectoryUITests; + PRODUCT_NAME = LMSDirectoryUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = OpenEdX; + VALIDATE_PRODUCT = YES; + }; + name = ReleaseDev; + }; + C6EAB3074FB344AB5BD633F6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.LMSDirectoryUITests; + PRODUCT_NAME = LMSDirectoryUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = OpenEdX; + }; + name = Debug; + }; + D19F2E12BFDCA2D242D62FCD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.LMSDirectoryUITests; + PRODUCT_NAME = LMSDirectoryUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = OpenEdX; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + E6433CFC8BF21E4A068F94C8 /* DebugProd */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.LMSDirectoryUITests; + PRODUCT_NAME = LMSDirectoryUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = OpenEdX; + }; + name = DebugProd; + }; + EC4767934CAC87A2A0B1A1B7 /* DebugDev */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_IDENTITY = ""; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.LMSDirectoryUITests; + PRODUCT_NAME = LMSDirectoryUITests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = OpenEdX; + }; + name = DebugDev; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index f5ad8d591..61bd09f13 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,21 @@ 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.isDirectoryReachable { + Container.shared.register(LMSSelectionRouting.self) { _ in + LMSDirectoryRouter() + }.inObjectScope(.container) + LMSDirectoryFeature.register(source: config.lmsDirectory.source) + } 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 { 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..b2e7c219c --- /dev/null +++ b/OpenEdX/LMSDirectoryRouter.swift @@ -0,0 +1,43 @@ +// +// 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() { + guard let router = Container.shared.resolve(Router.self) else { return } + router.getNavigationController().popToRootViewController(animated: false) + router.showDiscoveryScreen(searchQuery: nil, sourceScreen: .default) + } + + 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( + 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..2b3a2c659 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.isDirectoryReachable == 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/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 ba3524e12..81cbc2021 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 67a855c71..ec352b376 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -12,9 +12,9 @@ import Theme import OEXFoundation public struct ProfileView: View { - + @Bindable private var viewModel: ProfileViewModel - + public init(viewModel: ProfileViewModel) { self.viewModel = viewModel } @@ -82,7 +82,7 @@ public struct ProfileView: View { } } } - + private var progressBar: some View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 200) 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/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/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index f77af6ea9..f34bfb074 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import UIKit private let fontsParser = FontParser() @@ -96,6 +97,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, @@ -139,11 +141,17 @@ public struct Theme: Sendable { textInputPlaceholderColor: Color = ThemeAssets.textInputPlaceholderColor.swiftUIColor, infoColor: Color = ThemeAssets.infoColor.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, emptyStateIconColor: Color = ThemeAssets.emptyStateIconColor.swiftUIColor, secondaryContentColor: Color = ThemeAssets.secondaryContentColor.swiftUIColor ) { self.accentColor = accentColor self.accentXColor = accentXColor + self.accentButtonColor = accentButtonColor self.alert = alert self.avatarStroke = avatarStroke self.background = background @@ -187,6 +195,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 self.emptyStateIconColor = emptyStateIconColor self.secondaryContentColor = secondaryContentColor } @@ -354,3 +367,37 @@ extension View { return self } } + +extension Theme { + /// Images the app swaps at runtime, alongside `Theme.Colors`. + /// + /// The header background is held as a decoded image rather than a URL so the + /// screens that show it can draw it in the first frame. Whoever selects an + /// LMS is responsible for having the image in hand before calling `update` — + /// see `LMSThemeApplier` — which is what stops the header fading in after the + /// rest of the sign-in screen has already appeared. + public enum Images { + nonisolated(unsafe) public private(set) static var headerBackground: UIImage? + + public static func update(headerBackground: UIImage? = nil) { + Images.headerBackground = headerBackground + } + } +} + +/// Displays the auth/settings header background image. +/// Uses the LMS-provided image when one has been loaded, falling back to the +/// default `ThemeAssets.headerBackground`. +public struct LmsHeaderBackground: View { + public init() {} + + public var body: some View { + if let image = Theme.Images.headerBackground { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + ThemeAssets.headerBackground.swiftUIImage.resizable() + } + } +} diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 48d005148..817fdec76 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -21,3 +21,20 @@ UI_COMPONENTS: LOGIN_REGISTRATION_ENABLED: true SAML_SSO_LOGIN_ENABLED: false SAML_SSO_DEFAULT_LOGIN_BUTTON: false + +# Multi-tenant: let a learner choose which Open edX platform to sign in to. +# Off by default — with ENABLED false the app is a stock single-tenant build. +# +# The list of platforms is one JSON document. Give it either way, not both: +# +# DIRECTORY_URL: "https://example.com/lms_directory.json" fetched once +# DIRECTORY_FILE: "lms_directory.json" shipped in the app +# +# A bundled file wins over an address: a build that ships its own copy has +# deliberately opted out of the network. Image fields inside the document are +# either web addresses or names of files shipped with the app. +# See Documentation/LMS_DIRECTORY.md for the format. +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_FILE: "" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index e75c30495..f9bd4c58a 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -22,3 +22,20 @@ UI_COMPONENTS: LOGIN_REGISTRATION_ENABLED: true SAML_SSO_LOGIN_ENABLED: false SAML_SSO_DEFAULT_LOGIN_BUTTON: false + +# Multi-tenant: let a learner choose which Open edX platform to sign in to. +# Off by default — with ENABLED false the app is a stock single-tenant build. +# +# The list of platforms is one JSON document. Give it either way, not both: +# +# DIRECTORY_URL: "https://example.com/lms_directory.json" fetched once +# DIRECTORY_FILE: "lms_directory.json" shipped in the app +# +# A bundled file wins over an address: a build that ships its own copy has +# deliberately opted out of the network. Image fields inside the document are +# either web addresses or names of files shipped with the app. +# See Documentation/LMS_DIRECTORY.md for the format. +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_FILE: "" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 117a7f49e..6fc902388 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -21,3 +21,20 @@ UI_COMPONENTS: LOGIN_REGISTRATION_ENABLED: true SAML_SSO_LOGIN_ENABLED: false SAML_SSO_DEFAULT_LOGIN_BUTTON: false + +# Multi-tenant: let a learner choose which Open edX platform to sign in to. +# Off by default — with ENABLED false the app is a stock single-tenant build. +# +# The list of platforms is one JSON document. Give it either way, not both: +# +# DIRECTORY_URL: "https://example.com/lms_directory.json" fetched once +# DIRECTORY_FILE: "lms_directory.json" shipped in the app +# +# A bundled file wins over an address: a build that ships its own copy has +# deliberately opted out of the network. Image fields inside the document are +# either web addresses or names of files shipped with the app. +# See Documentation/LMS_DIRECTORY.md for the format. +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_FILE: "" From 9392ce0c573203998f37733da614113286ff4e79 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:38:45 +0300 Subject: [PATCH 2/5] style: follow the project's file header convention, and redraw the header image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every file here now opens with the same five-line block the rest of the repository uses, and the explanation that was sitting in those blocks moved onto the declaration it describes, as a doc comment. One real fix came out of comparing the two platforms side by side. Theme.Images.headerBackground is a plain static, so a screen that had already drawn kept the stock artwork even after the selected platform's image arrived — visible on a cold start, where the picker's prefetch has not run. It now posts a change notification and the header view listens for it. Android, whose Coil fills the image in when it lands, was already right. --- .../TenantPicker/LMSDirectoryAnalytics.swift | 7 +++++ .../TenantPicker/LMSDirectoryFeature.swift | 7 +++++ .../LMSDirectoryLandingView.swift | 3 +- .../TenantPicker/LMSDirectoryService.swift | 7 +++-- .../TenantPicker/LMSDirectoryView.swift | 5 ++-- .../TenantPicker/LMSDirectoryViewModel.swift | 7 +++-- .../Presentation/TenantPicker/LMSModels.swift | 7 +++++ .../TenantPicker/LMSOverridesStore.swift | 7 +++++ .../LMSSelectionCoordinator.swift | 7 +++++ .../TenantPicker/LMSThemeApplier.swift | 7 +++++ .../StaticLMSDirectoryService.swift | 19 ++++++------ .../LMSDirectoryViewModelTests.swift | 12 ++++---- .../StaticLMSDirectoryServiceTests.swift | 12 ++++---- .../LMSDirectory/StubURLProtocol.swift | 8 +++-- .../Config/LMSDirectoryConfig.swift | 7 +++-- Core/Core/Configuration/LMSImageSource.swift | 14 ++++----- .../ConfigLMSDirectoryTests.swift | 12 ++++---- .../LMSDirectoryConfigSourceTests.swift | 10 ++++--- .../Configuration/LMSImageSourceTests.swift | 10 ++++--- OpenEdX/LMSDirectoryRouter.swift | 9 +++--- Theme/Theme/Theme.swift | 30 +++++++++++++++---- 21 files changed, 142 insertions(+), 65 deletions(-) diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift index 4abeb117c..a884e8137 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryAnalytics.swift @@ -1,3 +1,10 @@ +// +// LMSDirectoryAnalytics.swift +// Authorization +// +// Created by Ivan Stepanok on 20.08.2026. +// + import Foundation protocol LMSDirectoryAnalytics: Sendable { diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift index 209448d78..5ae4fc316 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryFeature.swift @@ -1,3 +1,10 @@ +// +// LMSDirectoryFeature.swift +// Authorization +// +// Created by Ivan Stepanok on 20.08.2026. +// + import Core import Foundation import SwiftUI diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift index c08652698..bfbe6fd04 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryLandingView.swift @@ -2,12 +2,13 @@ // LMSDirectoryLandingView.swift // Authorization // -// The first screen of a multi-tenant build: which platform is this? +// Created by Ivan Stepanok on 20.08.2026. // import SwiftUI import Theme +/// The first screen of a multi-tenant build: which platform is this? struct LMSDirectoryLandingView: View { @StateObject private var viewModel: LMSDirectoryViewModel diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift index 41fe5a787..6f3bc7fe7 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryService.swift @@ -2,9 +2,7 @@ // LMSDirectoryService.swift // Authorization // -// Where the app's list of platforms comes from. One implementation today — -// a JSON document, hosted or shipped with the app — behind a protocol so the -// screen does not care which of the two it got. +// Created by Ivan Stepanok on 20.08.2026. // import Core @@ -16,6 +14,9 @@ enum LMSDirectoryError: Error { case decodingFailed } +/// Where the app's list of platforms comes from. One implementation today — +/// a JSON document, hosted or shipped with the app — behind a protocol so the +/// screen does not care which of the two it got. protocol LMSDirectoryService: Sendable { /// Every platform in the directory, in the order the document lists them. func platforms() async throws -> [LMSSummary] diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift index 21c9552ef..f6bf56083 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryView.swift @@ -2,8 +2,7 @@ // LMSDirectoryView.swift // Authorization // -// The list of platforms the directory holds. A document is a fixed list, so -// this is a list and nothing else — no search box, and nothing to type. +// Created by Ivan Stepanok on 20.08.2026. // import Core @@ -11,6 +10,8 @@ import Kingfisher import SwiftUI import Theme +/// The list of platforms the directory holds. A document is a fixed list, so +/// this is a list and nothing else — no search box, and nothing to type. struct LMSDirectoryView: View { @ObservedObject private var viewModel: LMSDirectoryViewModel diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift index 653d5dbbd..087201b91 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift @@ -2,14 +2,15 @@ // LMSDirectoryViewModel.swift // Authorization // -// The platform picker: read the directory, show what is in it, and hand the -// chosen platform to the coordinator that re-themes the app and routes on to -// sign-in. +// Created by Ivan Stepanok on 20.08.2026. // import Core import Foundation +/// The platform picker: read the directory, show what is in it, and hand the +/// chosen platform to the coordinator that re-themes the app and routes on to +/// sign-in. @MainActor final class LMSDirectoryViewModel: ObservableObject { diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift index dbff89922..0513af10a 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift @@ -1,3 +1,10 @@ +// +// LMSModels.swift +// Authorization +// +// Created by Ivan Stepanok on 20.08.2026. +// + import Foundation struct LMSSummary: Identifiable, Hashable, Sendable { diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift index 82ac48db2..b890562ea 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift @@ -1,3 +1,10 @@ +// +// LMSOverridesStore.swift +// Authorization +// +// Created by Ivan Stepanok on 20.08.2026. +// + import Core import Foundation diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift index edbeb3d9c..0d642ab0d 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSSelectionCoordinator.swift @@ -1,3 +1,10 @@ +// +// LMSSelectionCoordinator.swift +// Authorization +// +// Created by Ivan Stepanok on 20.08.2026. +// + import Core import Foundation import Swinject diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift index 201d16cf9..cd49527ba 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSThemeApplier.swift @@ -1,3 +1,10 @@ +// +// LMSThemeApplier.swift +// Authorization +// +// Created by Ivan Stepanok on 20.08.2026. +// + import Core import Kingfisher import SwiftUI diff --git a/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift b/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift index c455229bb..d46da83ec 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift @@ -2,16 +2,7 @@ // StaticLMSDirectoryService.swift // Authorization // -// Reads the whole directory from a single JSON document instead of a live API. -// -// The document can come from a URL or from a file inside the app bundle, and -// nothing downstream can tell the difference. That is the point: an operator -// publishes the file wherever they like — their own web server, a CDN, or the -// app binary itself — and the app never learns anything about where it lives. -// -// Everything arrives at once, so the platform list and every platform's details -// are known before the learner taps anything. That is what makes it possible to -// warm the logos and sign-in backgrounds ahead of the screen that shows them. +// Created by Ivan Stepanok on 20.08.2026. // import Core @@ -57,6 +48,14 @@ enum LMSDirectoryDocumentSource: Sendable, Equatable { } } +/// Reads the whole directory from a single JSON document instead of a live API. +/// +/// The document can come from a URL or from a file inside the app bundle, and +/// nothing downstream can tell the difference: an operator publishes the file +/// wherever they like and the app never learns anything about where it lives. +/// Everything arrives at once, so the list and every platform's details are +/// known before the learner taps anything — which is what makes it possible to +/// warm the logos and sign-in backgrounds ahead of the screen that shows them. final class StaticLMSDirectoryService: LMSDirectoryService { private let source: LMSDirectoryDocumentSource diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift index b80a27a3f..d3e4f5181 100644 --- a/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/LMSDirectoryViewModelTests.swift @@ -1,11 +1,8 @@ // // LMSDirectoryViewModelTests.swift -// AuthorizationTests +// Authorization // -// The platform picker reads a fixed list and hands one platform to the -// coordinator. These cover what the learner sees while that happens: the list, -// an empty directory, a document that could not be read, and the selection -// actually reaching the coordinator that re-themes the app. +// Created by Ivan Stepanok on 20.08.2026. // import XCTest @@ -13,6 +10,11 @@ import Foundation @testable import Core @testable import Authorization +/// AuthorizationTests +/// The platform picker reads a fixed list and hands one platform to the +/// coordinator. These cover what the learner sees while that happens: the list, +/// an empty directory, a document that could not be read, and the selection +/// actually reaching the coordinator that re-themes the app. @MainActor final class LMSDirectoryViewModelTests: XCTestCase { diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift index 44a154682..fcfcde0d6 100644 --- a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift @@ -1,17 +1,19 @@ // // StaticLMSDirectoryServiceTests.swift -// AuthorizationTests +// Authorization // -// A directory read from a single JSON document — hosted or shipped inside the -// app — has to behave exactly like one read from a live service, and it has to -// keep behaving that way with no network at all. That is the whole promise of -// the document, so these are the tests that hold it. +// Created by Ivan Stepanok on 20.08.2026. // import Core import XCTest @testable import Authorization +/// AuthorizationTests +/// A directory read from a single JSON document — hosted or shipped inside the +/// app — has to behave exactly like one read from a live service, and it has to +/// keep behaving that way with no network at all. That is the whole promise of +/// the document, so these are the tests that hold it. final class StaticLMSDirectoryServiceTests: XCTestCase { private func makeSession() -> URLSession { diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StubURLProtocol.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StubURLProtocol.swift index a42b2e999..e112a5ef9 100644 --- a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StubURLProtocol.swift +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StubURLProtocol.swift @@ -1,13 +1,15 @@ // // StubURLProtocol.swift -// AuthorizationTests +// Authorization // -// Answers the directory document without a network, so the tests around it can -// say exactly what came back — including nothing, and a connection that dropped. +// Created by Ivan Stepanok on 20.08.2026. // import Foundation +/// AuthorizationTests +/// Answers the directory document without a network, so the tests around it can +/// say exactly what came back — including nothing, and a connection that dropped. final class StubURLProtocol: URLProtocol { nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? diff --git a/Core/Core/Configuration/Config/LMSDirectoryConfig.swift b/Core/Core/Configuration/Config/LMSDirectoryConfig.swift index 25917139c..daefde39f 100644 --- a/Core/Core/Configuration/Config/LMSDirectoryConfig.swift +++ b/Core/Core/Configuration/Config/LMSDirectoryConfig.swift @@ -2,9 +2,7 @@ // LMSDirectoryConfig.swift // Core // -// Feature flag for the multi-tenant LMS Directory: a build that lets a learner -// choose which Open edX platform to sign in to. With ENABLED false the app -// behaves exactly like a stock single-tenant build. +// Created by Ivan Stepanok on 20.08.2026. // import Foundation @@ -16,6 +14,9 @@ private enum Keys: String, RawStringExtractable { case directoryFile = "DIRECTORY_FILE" } +/// Feature flag for the multi-tenant LMS Directory: a build that lets a learner +/// choose which Open edX platform to sign in to. With ENABLED false the app +/// behaves exactly like a stock single-tenant build. public class LMSDirectoryConfig: NSObject { /// Master gate. When false the feature is completely inert. public var enabled: Bool diff --git a/Core/Core/Configuration/LMSImageSource.swift b/Core/Core/Configuration/LMSImageSource.swift index 5f1ac478f..77a45534e 100644 --- a/Core/Core/Configuration/LMSImageSource.swift +++ b/Core/Core/Configuration/LMSImageSource.swift @@ -2,18 +2,18 @@ // LMSImageSource.swift // Core // -// Where a directory image actually comes from. -// -// The directory document carries image fields as plain strings. A string that -// looks like a web address is fetched; anything else is the name of a file -// shipped inside the app. That one rule is what lets the same document work for -// an operator who hosts their images and for one who bundles them, without a -// second set of fields to keep in step. +// Created by Ivan Stepanok on 20.08.2026. // import Foundation import UIKit +/// Where a directory image actually comes from. +/// The directory document carries image fields as plain strings. A string that +/// looks like a web address is fetched; anything else is the name of a file +/// shipped inside the app. That one rule is what lets the same document work for +/// an operator who hosts their images and for one who bundles them, without a +/// second set of fields to keep in step. public enum LMSImageSource: Sendable, Hashable { /// An http(s) address to download. case remote(URL) diff --git a/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift b/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift index fb566f4a1..f53b115e7 100644 --- a/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift +++ b/Core/CoreTests/Configuration/ConfigLMSDirectoryTests.swift @@ -1,16 +1,18 @@ // // ConfigLMSDirectoryTests.swift -// CoreTests +// Core // -// 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. +// Created by Ivan Stepanok on 20.08.2026. // import XCTest @testable import Core +/// 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. final class ConfigLMSDirectoryTests: XCTestCase { private let baseURLKey = "selectedLMSBaseURL" diff --git a/Core/CoreTests/Configuration/LMSDirectoryConfigSourceTests.swift b/Core/CoreTests/Configuration/LMSDirectoryConfigSourceTests.swift index 37d9fd898..06d70235d 100644 --- a/Core/CoreTests/Configuration/LMSDirectoryConfigSourceTests.swift +++ b/Core/CoreTests/Configuration/LMSDirectoryConfigSourceTests.swift @@ -1,15 +1,17 @@ // // LMSDirectoryConfigSourceTests.swift -// CoreTests +// Core // -// Which document a build reads is decided entirely by the config file, and the -// mistakes are invisible until someone ships: a build that quietly ignores the -// copy it bundled, or one that thinks it has a directory when it has nothing. +// Created by Ivan Stepanok on 20.08.2026. // import XCTest @testable import Core +/// CoreTests +/// Which document a build reads is decided entirely by the config file, and the +/// mistakes are invisible until someone ships: a build that quietly ignores the +/// copy it bundled, or one that thinks it has a directory when it has nothing. final class LMSDirectoryConfigSourceTests: XCTestCase { private func config(_ dict: [String: Any]) -> LMSDirectoryConfig { diff --git a/Core/CoreTests/Configuration/LMSImageSourceTests.swift b/Core/CoreTests/Configuration/LMSImageSourceTests.swift index 03a245f37..f1c19d7f1 100644 --- a/Core/CoreTests/Configuration/LMSImageSourceTests.swift +++ b/Core/CoreTests/Configuration/LMSImageSourceTests.swift @@ -1,15 +1,17 @@ // // LMSImageSourceTests.swift -// CoreTests +// Core // -// One field decides whether an image is downloaded or read out of the app. The -// rule is simple enough to state in a sentence, which is exactly why it needs -// tests: an operator editing the document by hand will lean on it. +// Created by Ivan Stepanok on 20.08.2026. // import XCTest @testable import Core +/// CoreTests +/// One field decides whether an image is downloaded or read out of the app. The +/// rule is simple enough to state in a sentence, which is exactly why it needs +/// tests: an operator editing the document by hand will lean on it. final class LMSImageSourceTests: XCTestCase { func testWebAddressesAreDownloaded() { diff --git a/OpenEdX/LMSDirectoryRouter.swift b/OpenEdX/LMSDirectoryRouter.swift index b2e7c219c..a40615fd3 100644 --- a/OpenEdX/LMSDirectoryRouter.swift +++ b/OpenEdX/LMSDirectoryRouter.swift @@ -2,10 +2,7 @@ // 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. +// Created by Ivan Stepanok on 20.08.2026. // import UIKit @@ -14,6 +11,10 @@ import Core import Authorization import Swinject +/// 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. final class LMSDirectoryRouter: LMSSelectionRouting { func presentDiscovery() { diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index f34bfb074..3b657abf5 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -379,8 +379,16 @@ extension Theme { public enum Images { nonisolated(unsafe) public private(set) static var headerBackground: UIImage? + /// Announces that the header image changed. + /// + /// The value is a plain static, so a view that already drew without it + /// would keep the stock artwork until something else redrew it. That is + /// what a cold start looks like when the picture is still arriving. + public static let didChange = Notification.Name("Theme.Images.headerBackgroundDidChange") + public static func update(headerBackground: UIImage? = nil) { Images.headerBackground = headerBackground + NotificationCenter.default.post(name: didChange, object: nil) } } } @@ -389,15 +397,25 @@ extension Theme { /// Uses the LMS-provided image when one has been loaded, falling back to the /// default `ThemeAssets.headerBackground`. public struct LmsHeaderBackground: View { + @State private var image: UIImage? = Theme.Images.headerBackground + public init() {} public var body: some View { - if let image = Theme.Images.headerBackground { - Image(uiImage: image) - .resizable() - .scaledToFill() - } else { - ThemeAssets.headerBackground.swiftUIImage.resizable() + Group { + if let image { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + ThemeAssets.headerBackground.swiftUIImage.resizable() + } + } + // A cold start can draw this before the selected platform's artwork has + // arrived. Without this the stock header would stay for the life of the + // screen, which is not what the other platform does. + .onReceive(NotificationCenter.default.publisher(for: Theme.Images.didChange)) { _ in + image = Theme.Images.headerBackground } } } From 5ceced687b5ce3428b40b21a3a949593c7250787 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:49:09 +0300 Subject: [PATCH 3/5] fix: a platform in the directory need not carry its own OAuth client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile working group settled this the other way round from how the file was written: a multi-instance app has one public OAuth client id of its own, and each backend registers that id — the directory is not where per-platform credentials live. The runtime already worked that way: Config falls back to the app's own client id when no platform has been selected or the selected one names none. The wire format did not — `api` and `oauth_client_id` were required, so a document written to the agreed model failed to decode here while Android, whose fields are already nullable, read it fine. `api` is now optional in full, `host_url` defaults to the address the learner picked, and a missing client id means "the app's own". A platform that does name one still overrides, for that platform only. --- .../Presentation/TenantPicker/LMSModels.swift | 23 +++++++---- .../StaticLMSDirectoryServiceTests.swift | 38 ++++++++++++++++--- Documentation/LMS_DIRECTORY.md | 26 ++++++++----- 3 files changed, 65 insertions(+), 22 deletions(-) diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift index 0513af10a..7fd963dc9 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift @@ -138,10 +138,16 @@ struct LMSColor: Sendable, Hashable { // MARK: - Wire format struct LMSDetailDTO: Codable { + /// Every field here is optional on purpose. + /// + /// A multi-instance app carries one OAuth client id of its own, which each + /// backend registers; the directory is not where per-platform credentials + /// live. A file that says nothing about any of this is the normal case, and + /// the app falls back to its own configuration. struct APIDTO: Codable { - let hostURL: URL - let feedbackEmail: String - let oauthClientId: String + let hostURL: URL? + let feedbackEmail: String? + let oauthClientId: String? enum CodingKeys: String, CodingKey { case hostURL = "host_url" @@ -153,7 +159,7 @@ struct LMSDetailDTO: Codable { let id: String let title: String let description: String - let api: APIDTO + let api: APIDTO? let featureFlags: LMSDetail.FeatureFlags? let theme: LMSDetail.Theme? let uiComponents: LMSDetail.UIComponents? @@ -184,9 +190,12 @@ struct LMSDetailDTO: Codable { title: title, description: description, api: .init( - hostURL: api.hostURL, - feedbackEmail: api.feedbackEmail, - oauthClientId: api.oauthClientId + // A platform that names no separate API host is served from the + // same address the learner picked. + hostURL: api?.hostURL ?? baseURL, + feedbackEmail: api?.feedbackEmail ?? "", + // Empty means "the app's own", which is what Config falls back to. + oauthClientId: api?.oauthClientId ?? "" ), featureFlags: featureFlags ?? .none, theme: theme, diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift index fcfcde0d6..6928f66d6 100644 --- a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift @@ -135,12 +135,7 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { "title": "Alpha", "description": "Alpha campus", "short_description": "Alpha", - "base_url": "https://alpha.example.edu", - "api": { - "host_url": "https://alpha.example.edu", - "feedback_email": "support@example.edu", - "oauth_client_id": "alpha-client" - } + "base_url": "https://alpha.example.edu" } ] } @@ -151,6 +146,37 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { XCTAssertFalse(detail.featureFlags.preLoginDiscovery) XCTAssertNil(detail.featureFlags.unknownUnitsMode) XCTAssertNil(detail.logoURL) + // No "api" block at all: the platform is served from the address the + // learner picked, and the app signs in with its own OAuth client. + XCTAssertEqual(detail.api.hostURL.absoluteString, "https://alpha.example.edu") + XCTAssertTrue(detail.api.oauthClientId.isEmpty) + } + + /// A multi-instance app carries one OAuth client id of its own, which each + /// backend registers. A directory that names none per platform is the normal + /// case, and must not stop the file being read. + func testAPlatformNeedNotCarryItsOwnOAuthClient() async throws { + let document = """ + { + "version": 1, + "platforms": [ + { + "id": "1", + "title": "Alpha", + "description": "Alpha campus", + "short_description": "Alpha", + "base_url": "https://alpha.example.edu", + "api": { "host_url": "https://api.alpha.example.edu" } + } + ] + } + """ + + let detail = try await makeRemoteService(body: document).details(id: "1") + + XCTAssertEqual(detail.api.hostURL.absoluteString, "https://api.alpha.example.edu") + XCTAssertTrue(detail.api.oauthClientId.isEmpty) + XCTAssertTrue(detail.api.feedbackEmail.isEmpty) } func testMalformedDocumentReportsDecodingFailure() async { diff --git a/Documentation/LMS_DIRECTORY.md b/Documentation/LMS_DIRECTORY.md index 0cffceb2b..fc79c9fa4 100644 --- a/Documentation/LMS_DIRECTORY.md +++ b/Documentation/LMS_DIRECTORY.md @@ -55,9 +55,7 @@ One JSON file. This is the whole format: "visibility": "public", "featured": false, "api": { - "host_url": "https://learn.northwind.edu", - "feedback_email": "support@northwind.edu", - "oauth_client_id": "PASTE_THE_MOBILE_OAUTH_CLIENT_ID" + "feedback_email": "support@northwind.edu" }, "feature_flags": { "pre_login_discovery": false, @@ -89,19 +87,29 @@ One JSON file. This is the whole format: | `title` | Shown in the list and on the sign-in screen. | | `description` / `short_description` | Long and one-line blurbs. | | `base_url` | The Open edX site. Must be `https` in a shipped build. | -| `api.host_url` | Usually the same as `base_url`. | -| `api.oauth_client_id` | The site's **mobile** OAuth client id. Sign-in fails without the right one. | -| `api.feedback_email` | May be `""`. | ### Optional -Everything else. Omit a key and the app uses its own default, so the smallest -useful entry is `id`, `title`, `description`, `short_description`, `base_url` -and `api`. `provider` is optional too; its `name` is shown above the list. +Everything else, `api` included. Omit a key and the app uses its own default, so +the smallest useful entry is `id`, `title`, `description`, `short_description` +and `base_url`. `provider` is optional too; its `name` is shown above the list. `visibility` and `featured` are accepted and ignored — every platform in the file is shown, in the order the file lists them. +### OAuth + +A multi-instance app carries **one** OAuth client id of its own — the one in +`config.yaml` — and each platform registers that id in its own OAuth +Applications table, ideally restricted to the app's redirect scheme. The +directory is not where per-platform credentials live, so `api` can be omitted +entirely and usually should be. + +`api` is still read when present: `host_url` for a platform whose API lives at a +different address than the one the learner picked, `oauth_client_id` for a +platform that insists on its own, and `feedback_email` for the support address. +A platform naming its own client id overrides the app's for that platform only. + ## Images Every image field takes either of two things, and the value itself says which: From 25c7b1cf1f1e8ea8ca710e5b0d2e8d3fd361337a Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:10:42 +0300 Subject: [PATCH 4/5] refactor: read the directory in the key names the working group agreed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file the apps read now says `format`, `include`, `name`, `description`, `url` and `logo`, matching the schema being settled in the mobile working group rather than the column names of the registry that happened to produce the first one. A file written by hand and a file exported from a registry are now the same shape, which was the point. Two things fall out of the rename. `id` becomes optional — a platform that names none is identified by its address, which is unique within a directory anyway, so the smallest useful entry is a name and a URL. And the long `description` is gone: it was carried through the model and never shown, while the line the list actually draws was called `short_description`, so that one takes the plain name. `logo_upload_url` goes with it. There is one logo per platform; which image that is, is the publisher's decision, not something the reader should arbitrate. --- .../TenantPicker/LMSDirectoryViewModel.swift | 9 ++-- .../Presentation/TenantPicker/LMSModels.swift | 42 +++++++--------- .../TenantPicker/LMSOverridesStore.swift | 6 +-- .../StaticLMSDirectoryService.swift | 14 +++--- .../StaticLMSDirectoryServiceTests.swift | 48 +++++++++---------- Documentation/LMS_DIRECTORY.md | 48 +++++++++---------- 6 files changed, 77 insertions(+), 90 deletions(-) diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift index 087201b91..8cb355ce6 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift @@ -112,8 +112,8 @@ private extension LMSDetail { func asDTO() -> LMSDetailDTO { LMSDetailDTO( id: id, - title: title, - description: description, + name: title, + description: shortDescription, api: .init( hostURL: api.hostURL, feedbackEmail: api.feedbackEmail, @@ -124,9 +124,8 @@ private extension LMSDetail { uiComponents: uiComponents, dashboard: dashboard, accentColor: accentColorHex, - shortDescription: shortDescription, - baseURL: baseURL, - logoURL: logoURL + url: baseURL, + logo: logoURL ) } } diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift index 7fd963dc9..e989ca162 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift @@ -52,12 +52,10 @@ struct LMSDetail: Identifiable, Hashable, Sendable { 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" + case loginBackgroundURL = "login_background" } } @@ -105,10 +103,6 @@ struct LMSDetail: Identifiable, Hashable, Sendable { } /// 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" @@ -156,22 +150,23 @@ struct LMSDetailDTO: Codable { } } - let id: String - let title: String - let description: String + /// Optional: a file that names no id is identified by its address, which is + /// unique within a directory anyway. + let id: String? + let name: 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? + let url: URL + let logo: URL? enum CodingKeys: String, CodingKey { case id - case title + case name case description case api case featureFlags = "feature_flags" @@ -179,20 +174,19 @@ struct LMSDetailDTO: Codable { case uiComponents = "ui_components" case dashboard case accentColor = "accent_color" - case shortDescription = "short_description" - case baseURL = "base_url" - case logoURL = "logo_url" + case url + case logo } var domainModel: LMSDetail { LMSDetail( - id: id, - title: title, - description: description, + id: id ?? url.absoluteString, + title: name, + description: description ?? "", api: .init( // A platform that names no separate API host is served from the // same address the learner picked. - hostURL: api?.hostURL ?? baseURL, + hostURL: api?.hostURL ?? url, feedbackEmail: api?.feedbackEmail ?? "", // Empty means "the app's own", which is what Config falls back to. oauthClientId: api?.oauthClientId ?? "" @@ -202,9 +196,9 @@ struct LMSDetailDTO: Codable { uiComponents: uiComponents, dashboard: dashboard, accentColorHex: accentColor, - shortDescription: shortDescription, - baseURL: baseURL, - logoURL: logoURL + shortDescription: description ?? "", + baseURL: url, + logoURL: logo ) } } diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift index b890562ea..a32c49ba0 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift @@ -28,7 +28,7 @@ final class LMSOverridesStore: LMSOverridesStoreProtocol { 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 logoURL = "lmsDirectory.selected_logo_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" @@ -52,7 +52,7 @@ final class LMSOverridesStore: LMSOverridesStoreProtocol { 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.logoURL?.absoluteString, forKey: Keys.logoURL) 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) @@ -76,7 +76,7 @@ final class LMSOverridesStore: LMSOverridesStoreProtocol { for key in [ Keys.selectionPayload, Keys.feedbackEmail, Keys.oauthClientId, Keys.accentColor, Keys.accentColorDark, Keys.unknownUnitsMode, - Keys.loginBackgroundURL, Keys.logoUploadURL, + Keys.loginBackgroundURL, Keys.logoURL, Keys.courseUnitProgress, Keys.courseDropdownNav, Keys.preLoginExperience, Keys.dashboardType ] { diff --git a/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift b/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift index d46da83ec..45acad6f9 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift @@ -15,18 +15,18 @@ struct LMSDirectoryDocumentDTO: Codable { struct Provider: Codable { let name: String let tagline: String? - let logoURL: URL? + let logo: URL? enum CodingKeys: String, CodingKey { case name case tagline - case logoURL = "logo_url" + case logo } } - let version: Int + let format: String? let provider: Provider? - let platforms: [LMSDetailDTO] + let include: [LMSDetailDTO] } /// Where a document is read from. @@ -99,7 +99,7 @@ final class StaticLMSDirectoryService: LMSDirectoryService { // MARK: - Private private func allPlatforms() async throws -> [LMSDetail] { - try await document().platforms.map(\.domainModel) + try await document().include.map(\.domainModel) } private func document() async throws -> LMSDirectoryDocumentDTO { @@ -176,13 +176,13 @@ private extension LMSDetail { title: title, shortDescription: shortDescription, baseURL: baseURL, - logoURL: effectiveLogoURL, + logoURL: logoURL, accentColorHex: accentColorHex ) } var imageSources: [LMSImageSource] { - [effectiveLogoURL, theme?.loginBackgroundURL] + [logoURL, theme?.loginBackgroundURL] .compactMap { $0 } .compactMap(LMSImageSource.init(url:)) } diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift index 6928f66d6..9ee7db00f 100644 --- a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift @@ -29,16 +29,15 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { private static let document = """ { - "version": 1, - "provider": { "name": "Northwind", "tagline": "Five campuses, one app", "logo_url": null }, - "platforms": [ + "format": "v1", + "provider": { "name": "Northwind", "tagline": "Five campuses, one app", "logo": null }, + "include": [ { "id": "1", - "title": "Alpha", - "description": "Alpha campus", - "short_description": "Alpha", - "base_url": "https://alpha.example.edu", - "logo_url": "https://cdn.example.com/alpha.png", + "name": "Alpha", + "description": "Alpha", + "url": "https://alpha.example.edu", + "logo": "https://cdn.example.com/alpha.png", "accent_color": "#112233", "api": { "host_url": "https://alpha.example.edu", @@ -46,15 +45,14 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { "oauth_client_id": "alpha-client" }, "feature_flags": { "pre_login_discovery": true, "unknown_units_mode": "block" }, - "theme": { "login_background_url": "alpha-bg.png", "accent_color_dark": "#445566" } + "theme": { "login_background": "alpha-bg.png", "accent_color_dark": "#445566" } }, { "id": "2", - "title": "Beta", - "description": "Beta campus", - "short_description": "Beta", - "base_url": "https://beta.example.edu", - "logo_url": "beta-logo.png", + "name": "Beta", + "description": "Beta", + "url": "https://beta.example.edu", + "logo": "beta-logo.png", "accent_color": null, "api": { "host_url": "https://beta.example.edu", @@ -128,14 +126,13 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { func testAMinimalHandWrittenDocumentIsAccepted() async throws { let minimal = """ { - "version": 1, - "platforms": [ + "format": "v1", + "include": [ { "id": "1", - "title": "Alpha", - "description": "Alpha campus", - "short_description": "Alpha", - "base_url": "https://alpha.example.edu" + "name": "Alpha", + "description": "Alpha", + "url": "https://alpha.example.edu" } ] } @@ -158,14 +155,13 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { func testAPlatformNeedNotCarryItsOwnOAuthClient() async throws { let document = """ { - "version": 1, - "platforms": [ + "format": "v1", + "include": [ { "id": "1", - "title": "Alpha", - "description": "Alpha campus", - "short_description": "Alpha", - "base_url": "https://alpha.example.edu", + "name": "Alpha", + "description": "Alpha", + "url": "https://alpha.example.edu", "api": { "host_url": "https://api.alpha.example.edu" } } ] diff --git a/Documentation/LMS_DIRECTORY.md b/Documentation/LMS_DIRECTORY.md index fc79c9fa4..6cc901bfc 100644 --- a/Documentation/LMS_DIRECTORY.md +++ b/Documentation/LMS_DIRECTORY.md @@ -37,23 +37,21 @@ One JSON file. This is the whole format: ```json { - "version": 1, + "format": "v1", "provider": { "name": "Northwind Education Group", "tagline": "Five campuses, one app", - "logo_url": null + "logo": null }, - "platforms": [ + "include": [ { + "name": "Northwind College", + "description": "Main campus", + "url": "https://learn.northwind.edu", + "logo": "https://cdn.northwind.edu/logo.png", + "id": "1", - "title": "Northwind College", - "description": "The main campus, offering undergraduate programmes.", - "short_description": "Main campus", - "base_url": "https://learn.northwind.edu", - "logo_url": "https://cdn.northwind.edu/logo.png", "accent_color": "#002545", - "visibility": "public", - "featured": false, "api": { "feedback_email": "support@northwind.edu" }, @@ -63,8 +61,7 @@ One JSON file. This is the whole format: }, "theme": { "accent_color_dark": "#4989bf", - "login_background_url": "https://cdn.northwind.edu/signin.png", - "logo_upload_url": null + "login_background": "https://cdn.northwind.edu/signin.png" }, "ui_components": { "course_unit_progress_enabled": true, @@ -81,21 +78,22 @@ One JSON file. This is the whole format: | field | what it is | | --- | --- | -| `version` | `1`. The only version there is. | -| `platforms[]` | At least one. An empty list gives the learner nothing to pick. | -| `id` | Unique within the file. A string, even when it looks like a number. | -| `title` | Shown in the list and on the sign-in screen. | -| `description` / `short_description` | Long and one-line blurbs. | -| `base_url` | The Open edX site. Must be `https` in a shipped build. | +| `format` | `"v1"`. The only version there is. | +| `include[]` | At least one platform. An empty list gives the learner nothing to pick. | +| `name` | Shown in the list and on the sign-in screen. | +| `url` | The Open edX site. Must be `https` in a shipped build. | ### Optional -Everything else, `api` included. Omit a key and the app uses its own default, so -the smallest useful entry is `id`, `title`, `description`, `short_description` -and `base_url`. `provider` is optional too; its `name` is shown above the list. +Everything else, `api` and `id` included. Omit a key and the app uses its own +default, so the smallest useful entry is `name` and `url`. A platform that names +no `id` is identified by its address, which is unique in a directory anyway. +`provider` is optional too; its `name` is shown above the list. -`visibility` and `featured` are accepted and ignored — every platform in the -file is shown, in the order the file lists them. +The key names follow the schema the Open edX mobile working group is settling +on, so a file written by hand and a file exported from a registry are the same +shape. Unknown keys are ignored, which is what lets a newer file stay readable +by an older build. ### OAuth @@ -117,8 +115,8 @@ Every image field takes either of two things, and the value itself says which: - something starting with `http://` or `https://` is downloaded; - anything else is the **name of a file shipped with the app**. -So `"logo_url": "https://cdn.northwind.edu/logo.png"` is fetched, and -`"logo_url": "northwind-logo.png"` is looked up in the app bundle. That is what makes a +So `"logo": "https://cdn.northwind.edu/logo.png"` is fetched, and +`"logo": "northwind-logo.png"` is looked up in the app bundle. That is what makes a fully offline build possible: put the images next to the document, refer to them by name, and the app never asks the network for a picture. From d31f36985aa7899640824be532a5343508ddec3d Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:47:29 +0300 Subject: [PATCH 5/5] refactor: drop the platform id, and identify a platform by its position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The id came from the registry that produced the first document, where it is a primary key. It means nothing to a client, so the schema is better without it. What replaces it is the position in the file, not the URL. Two entries may legitimately name the same address — the same LMS listed twice under different branding — and my own live directory does exactly that. Identifying by URL merges them: the list drew the second entry in the first row, and opening it would have handed over the first entry's branding and OAuth client. That is a wrong platform, not a cosmetic glitch, and it only shows up on a directory that happens to contain a duplicate. Both platforms now number the entries as they read them, and a test on each side holds a two-entry document that shares one address. --- .../TenantPicker/LMSDirectoryViewModel.swift | 1 - .../Presentation/TenantPicker/LMSModels.swift | 16 +++++--- .../TenantPicker/LMSOverridesStore.swift | 4 +- .../StaticLMSDirectoryService.swift | 4 +- .../StaticLMSDirectoryServiceTests.swift | 39 +++++++++++++++---- Documentation/LMS_DIRECTORY.md | 7 ++-- 6 files changed, 50 insertions(+), 21 deletions(-) diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift index 8cb355ce6..c50ffd5fc 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSDirectoryViewModel.swift @@ -111,7 +111,6 @@ final class LMSDirectoryViewModel: ObservableObject { private extension LMSDetail { func asDTO() -> LMSDetailDTO { LMSDetailDTO( - id: id, name: title, description: shortDescription, api: .init( diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift index e989ca162..6bb5bc31a 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSModels.swift @@ -150,9 +150,6 @@ struct LMSDetailDTO: Codable { } } - /// Optional: a file that names no id is identified by its address, which is - /// unique within a directory anyway. - let id: String? let name: String let description: String? let api: APIDTO? @@ -165,7 +162,6 @@ struct LMSDetailDTO: Codable { let logo: URL? enum CodingKeys: String, CodingKey { - case id case name case description case api @@ -178,9 +174,17 @@ struct LMSDetailDTO: Codable { case logo } - var domainModel: LMSDetail { + /** + The platform, identified by where it sits in the document. + + Position is the only thing guaranteed unique. Two entries may legitimately + share an address — the same LMS listed twice under different branding — and + identifying them by URL silently merges them: the list draws one of the two + and tapping it opens the other one's settings. + */ + func domainModel(id: String) -> LMSDetail { LMSDetail( - id: id ?? url.absoluteString, + id: id, title: name, description: description ?? "", api: .init( diff --git a/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift b/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift index a32c49ba0..d089a2d48 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/LMSOverridesStore.swift @@ -66,7 +66,9 @@ final class LMSOverridesStore: LMSOverridesStoreProtocol { else { return nil } - return dto.domainModel + // A stored selection is one platform, so its position is irrelevant; it + // only needs an id that is stable for the object it already holds. + return dto.domainModel(id: dto.url.absoluteString) } func clear(storage: CoreStorage?) throws { diff --git a/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift b/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift index 45acad6f9..dd335f9a2 100644 --- a/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift +++ b/Authorization/Authorization/Presentation/TenantPicker/StaticLMSDirectoryService.swift @@ -99,7 +99,9 @@ final class StaticLMSDirectoryService: LMSDirectoryService { // MARK: - Private private func allPlatforms() async throws -> [LMSDetail] { - try await document().include.map(\.domainModel) + try await document().include.enumerated().map { index, entry in + entry.domainModel(id: String(index)) + } } private func document() async throws -> LMSDirectoryDocumentDTO { diff --git a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift index 9ee7db00f..4fe4b8ac8 100644 --- a/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift +++ b/Authorization/AuthorizationTests/Presentation/LMSDirectory/StaticLMSDirectoryServiceTests.swift @@ -33,7 +33,6 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { "provider": { "name": "Northwind", "tagline": "Five campuses, one app", "logo": null }, "include": [ { - "id": "1", "name": "Alpha", "description": "Alpha", "url": "https://alpha.example.edu", @@ -48,7 +47,6 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { "theme": { "login_background": "alpha-bg.png", "accent_color_dark": "#445566" } }, { - "id": "2", "name": "Beta", "description": "Beta", "url": "https://beta.example.edu", @@ -95,15 +93,42 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { // The document is fetched once and kept; if this asked the network again it // would fail, because the stub is torn down below. StubURLProtocol.handler = nil - let detail = try await service.details(id: "2") + let detail = try await service.details(id: "1") XCTAssertEqual(detail.title, "Beta") XCTAssertEqual(detail.api.oauthClientId, "beta-client") } + /// Two entries may legitimately name the same address. Identifying a + /// platform by its URL merges them: the list draws one and opening it gives + /// the other one's settings. + func testTwoPlatformsMayShareAnAddressWithoutMerging() async throws { + let document = """ + { + "format": "v1", + "include": [ + { "name": "Alpha", "description": "Alpha", "url": "https://shared.example.edu", + "accent_color": "#111111" }, + { "name": "Beta", "description": "Beta", "url": "https://shared.example.edu", + "accent_color": "#222222" } + ] + } + """ + let service = makeRemoteService(body: document) + + let items = try await service.platforms() + XCTAssertEqual(items.map(\.title), ["Alpha", "Beta"]) + XCTAssertEqual(Set(items.map(\.id)).count, 2, "two rows must not collapse into one") + + // Opening the second row gives the second platform, not the first. + let second = try await service.details(id: items[1].id) + XCTAssertEqual(second.title, "Beta") + XCTAssertEqual(second.accentColorHex, "#222222") + } + func testUnknownIdIsNotFound() async throws { let service = makeRemoteService() do { - _ = try await service.details(id: "does-not-exist") + _ = try await service.details(id: "9") XCTFail("Expected notFound") } catch { XCTAssertEqual(error as? LMSDirectoryError, .notFound) @@ -129,7 +154,6 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { "format": "v1", "include": [ { - "id": "1", "name": "Alpha", "description": "Alpha", "url": "https://alpha.example.edu" @@ -137,7 +161,7 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { ] } """ - let detail = try await makeRemoteService(body: minimal).details(id: "1") + let detail = try await makeRemoteService(body: minimal).details(id: "0") XCTAssertEqual(detail.title, "Alpha") XCTAssertFalse(detail.featureFlags.preLoginDiscovery) @@ -158,7 +182,6 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { "format": "v1", "include": [ { - "id": "1", "name": "Alpha", "description": "Alpha", "url": "https://alpha.example.edu", @@ -168,7 +191,7 @@ final class StaticLMSDirectoryServiceTests: XCTestCase { } """ - let detail = try await makeRemoteService(body: document).details(id: "1") + let detail = try await makeRemoteService(body: document).details(id: "0") XCTAssertEqual(detail.api.hostURL.absoluteString, "https://api.alpha.example.edu") XCTAssertTrue(detail.api.oauthClientId.isEmpty) diff --git a/Documentation/LMS_DIRECTORY.md b/Documentation/LMS_DIRECTORY.md index 6cc901bfc..c01c54db3 100644 --- a/Documentation/LMS_DIRECTORY.md +++ b/Documentation/LMS_DIRECTORY.md @@ -50,7 +50,6 @@ One JSON file. This is the whole format: "url": "https://learn.northwind.edu", "logo": "https://cdn.northwind.edu/logo.png", - "id": "1", "accent_color": "#002545", "api": { "feedback_email": "support@northwind.edu" @@ -85,9 +84,9 @@ One JSON file. This is the whole format: ### Optional -Everything else, `api` and `id` included. Omit a key and the app uses its own -default, so the smallest useful entry is `name` and `url`. A platform that names -no `id` is identified by its address, which is unique in a directory anyway. +Everything else, `api` included. Omit a key and the app uses its own default, so +the smallest useful entry is `name` and `url`. A platform is identified by its +address, so there is no separate id to keep in step with anything. `provider` is optional too; its `name` is shown above the list. The key names follow the schema the Open edX mobile working group is settling