diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index a56cb62d2..d0b26ebf7 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF4928D9F0A700835477 /* DateExtension.swift */; }; 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */; }; 12624ABABF5AE7FBF7B4F5E5 /* InstancesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */; }; + 41A744FEB981583CE4B1DEB8 /* InstanceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CE4D43A09B50EB85B793D5 /* InstanceStore.swift */; }; 064987932B4D69FF0071642A /* DragAndDropCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */; }; 064987942B4D69FF0071642A /* WebviewInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878B2B4D69FE0071642A /* WebviewInjection.swift */; }; 064987952B4D69FF0071642A /* SurveyCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */; }; @@ -146,6 +147,7 @@ BAD9CA2F2B289B3500DE790A /* ajaxHandler.js in Resources */ = {isa = PBXBuildFile; fileRef = BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */; }; BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */; }; BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */; }; + EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */; }; 9C33F22BA8733548621D5A97 /* InstancesConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDE56371521165CD47B36418 /* InstancesConfigTests.swift */; }; BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */; }; BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */; }; @@ -293,6 +295,7 @@ 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 = ""; }; 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIComponentsConfig.swift; sourceTree = ""; }; 2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesConfig.swift; sourceTree = ""; }; + 53CE4D43A09B50EB85B793D5 /* InstanceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStore.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 = ""; }; 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SurveyCssInjection.swift; sourceTree = ""; }; @@ -365,6 +368,7 @@ BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ajaxHandler.js; sourceTree = ""; }; BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AjaxProvider.swift; sourceTree = ""; }; BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = ""; }; + A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStoreTests.swift; sourceTree = ""; }; BDE56371521165CD47B36418 /* InstancesConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesConfigTests.swift; sourceTree = ""; }; BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookConfig.swift; sourceTree = ""; }; BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftConfig.swift; sourceTree = ""; }; @@ -894,6 +898,7 @@ children = ( 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */, 2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */, + 53CE4D43A09B50EB85B793D5 /* InstanceStore.swift */, 0727876F28D23411002E9142 /* Config.swift */, DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */, @@ -927,6 +932,7 @@ children = ( E09179FC2B0F204D002AB695 /* ConfigTests.swift */, BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */, + A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */, BDE56371521165CD47B36418 /* InstancesConfigTests.swift */, ); path = Configuration; @@ -1157,6 +1163,7 @@ buildActionMask = 2147483647; files = ( BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */, + EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */, 9C33F22BA8733548621D5A97 /* InstancesConfigTests.swift in Sources */, CE953A3B2CD0DA940023D667 /* CoreMocks.generated.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, @@ -1309,6 +1316,7 @@ 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */, 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */, 12624ABABF5AE7FBF7B4F5E5 /* InstancesConfig.swift in Sources */, + 41A744FEB981583CE4B1DEB8 /* InstanceStore.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */, 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */, diff --git a/Core/Core/Configuration/Config/InstanceStore.swift b/Core/Core/Configuration/Config/InstanceStore.swift new file mode 100644 index 000000000..13bfdd1a1 --- /dev/null +++ b/Core/Core/Configuration/Config/InstanceStore.swift @@ -0,0 +1,94 @@ +// +// InstanceStore.swift +// Core +// +// Created by Rawan Matar on 31/08/2026. +// + +import Foundation + +extension Notification.Name { + public static let instanceDidChange = Notification.Name("org.openedx.core.instanceDidChange") +} + +/// Single source of truth for the selected instance and the instance catalog. +public final class InstanceStore: InstanceProvider, @unchecked Sendable { + + private static let userDefaultsKey = "selectedInstanceKey" + + private let lock = NSLock() + private let userDefaults: UserDefaults + private let localInstancesConfig: () -> InstancesConfig + private var _remoteInstancesConfig: InstancesConfig? + private var _currentInstance: Instance? + + public var currentInstance: Instance? { + lock.lock(); defer { lock.unlock() } + return _currentInstance + } + + /// The catalog currently in effect: the remote one once set, else `localInstancesConfig`. + public var instancesConfig: InstancesConfig { + lock.lock(); defer { lock.unlock() } + return _remoteInstancesConfig ?? localInstancesConfig() + } + + public init( + userDefaults: UserDefaults = .standard, + instancesConfig: @escaping () -> InstancesConfig = { InstancesConfig() } + ) { + self.userDefaults = userDefaults + self.localInstancesConfig = instancesConfig + restoreSelection() + } + + /// Replaces the effective catalog and re-resolves the persisted selection against it. + public func updateInstancesConfig(_ instancesConfig: InstancesConfig) { + lock.lock() + _remoteInstancesConfig = instancesConfig + lock.unlock() + restoreSelection() + } + + /// Selects an instance (or clears it with `nil`) and persists the choice. + public func select(_ instance: Instance?) { + lock.lock() + _currentInstance = instance + lock.unlock() + + if let instance { + userDefaults.set(instance.key, forKey: Self.userDefaultsKey) + } else { + userDefaults.removeObject(forKey: Self.userDefaultsKey) + } + + NotificationCenter.default.post(name: .instanceDidChange, object: instance) + + // Re-run auto-select so a deselect on a single-instance catalog doesn't strand + // `currentInstance` at nil until the next launch. + if instance == nil { + restoreSelection() + } + } + + public func reset() { + select(nil) + } + + private func restoreSelection() { + let catalog = instancesConfig + let savedKey = userDefaults.string(forKey: Self.userDefaultsKey) + let resolved = savedKey.flatMap { catalog.instance(withKey: $0) } + + // Auto-select the sole instance in a single-instance catalog — there's no picker + // to choose it through otherwise. + if resolved == nil, catalog.instances.count == 1, let onlyInstance = catalog.instances.first { + select(onlyInstance) + return + } + + lock.lock() + _currentInstance = resolved + lock.unlock() + } +} diff --git a/Core/CoreTests/Configuration/InstanceStoreTests.swift b/Core/CoreTests/Configuration/InstanceStoreTests.swift new file mode 100644 index 000000000..32e1a0e7f --- /dev/null +++ b/Core/CoreTests/Configuration/InstanceStoreTests.swift @@ -0,0 +1,103 @@ +// +// InstanceStoreTests.swift +// CoreTests +// +// Created by Rawan Matar on 31/08/2026. +// + +import XCTest +@testable import Core + +final class InstanceStoreTests: XCTestCase { + + private func freshUserDefaults(_ name: String) -> UserDefaults { + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + func test_singleInstanceCatalog_noPriorSelection_autoSelectsTheOnlyInstance() { + let onlyInstance = Instance.mock(key: "acme") + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "NAME": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + + XCTAssertEqual(store.currentInstance?.key, onlyInstance.key) + // Persisted, so it survives relaunch and .instanceDidChange observers see it. + XCTAssertEqual(defaults.string(forKey: "selectedInstanceKey"), "acme") + } + + func test_multiInstanceCatalog_noPriorSelection_doesNotAutoSelect() { + let catalog = InstancesConfig(array: [ + ["NAME": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"], + ["NAME": "Beta", "OAUTH_CLIENT_ID": "beta-id", "API_HOST_URL": "https://beta.example.com"] + ]) + let defaults = freshUserDefaults(#function) + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + + XCTAssertNil(store.currentInstance) + } + + func test_singleInstanceCatalog_staleSavedKey_reselectsTheOnlyInstance() { + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "NAME": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + defaults.set("some-other-instance", forKey: "selectedInstanceKey") + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + + XCTAssertEqual(store.currentInstance?.key, "acme") + } + + func test_singleInstanceCatalog_matchingSavedKey_resolvesWithoutReselecting() { + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "NAME": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + defaults.set("acme", forKey: "selectedInstanceKey") + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + + XCTAssertEqual(store.currentInstance?.key, "acme") + } + + func test_emptyCatalog_noPriorSelection_leavesCurrentInstanceNil() { + let defaults = freshUserDefaults(#function) + + let store = InstanceStore(userDefaults: defaults, instancesConfig: { InstancesConfig() }) + + XCTAssertNil(store.currentInstance) + } + + func test_singleInstanceCatalog_deselecting_reselectsTheOnlyInstance() { + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "NAME": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"] + ]) + let defaults = freshUserDefaults(#function) + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + XCTAssertEqual(store.currentInstance?.key, "acme") + + store.select(nil) + + XCTAssertEqual(store.currentInstance?.key, "acme") + } + + func test_multiInstanceCatalog_deselecting_staysDeselected() { + let catalog = InstancesConfig(array: [ + ["KEY": "acme", "NAME": "Acme", "OAUTH_CLIENT_ID": "acme-id", "API_HOST_URL": "https://acme.example.com"], + ["KEY": "beta", "NAME": "Beta", "OAUTH_CLIENT_ID": "beta-id", "API_HOST_URL": "https://beta.example.com"] + ]) + let defaults = freshUserDefaults(#function) + let store = InstanceStore(userDefaults: defaults, instancesConfig: { catalog }) + store.select(catalog.instances[0]) + + store.select(nil) + + XCTAssertNil(store.currentInstance) + } +} diff --git a/OpenEdX/DI/NetworkAssembly.swift b/OpenEdX/DI/NetworkAssembly.swift index 904c1ea44..5b0f7bb55 100644 --- a/OpenEdX/DI/NetworkAssembly.swift +++ b/OpenEdX/DI/NetworkAssembly.swift @@ -13,6 +13,14 @@ import Swinject class NetworkAssembly: Assembly { func assemble(container: Container) { + container.register(InstanceStore.self) { _ in + InstanceStore() + }.inObjectScope(.container) + + container.register(InstanceProvider.self) { r in + r.resolve(InstanceStore.self)! + }.inObjectScope(.container) + container.register(RequestInterceptor.self) { r in RequestInterceptor(config: r.resolve(ConfigProtocol.self)!, storage: r.resolve(CoreStorage.self)!) }.inObjectScope(.container)