Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Core/Core.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIComponentsConfig.swift; sourceTree = "<group>"; };
2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesConfig.swift; sourceTree = "<group>"; };
53CE4D43A09B50EB85B793D5 /* InstanceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStore.swift; sourceTree = "<group>"; };
0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DragAndDropCssInjection.swift; sourceTree = "<group>"; };
0649878B2B4D69FE0071642A /* WebviewInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebviewInjection.swift; sourceTree = "<group>"; };
0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SurveyCssInjection.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -365,6 +368,7 @@
BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ajaxHandler.js; sourceTree = "<group>"; };
BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AjaxProvider.swift; sourceTree = "<group>"; };
BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = "<group>"; };
A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStoreTests.swift; sourceTree = "<group>"; };
BDE56371521165CD47B36418 /* InstancesConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesConfigTests.swift; sourceTree = "<group>"; };
BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookConfig.swift; sourceTree = "<group>"; };
BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftConfig.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -894,6 +898,7 @@
children = (
0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */,
2FC6455FBE49B22E32CB777A /* InstancesConfig.swift */,
53CE4D43A09B50EB85B793D5 /* InstanceStore.swift */,
0727876F28D23411002E9142 /* Config.swift */,
DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */,
A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */,
Expand Down Expand Up @@ -927,6 +932,7 @@
children = (
E09179FC2B0F204D002AB695 /* ConfigTests.swift */,
BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */,
A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */,
BDE56371521165CD47B36418 /* InstancesConfigTests.swift */,
);
path = Configuration;
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
94 changes: 94 additions & 0 deletions Core/Core/Configuration/Config/InstanceStore.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
103 changes: 103 additions & 0 deletions Core/CoreTests/Configuration/InstanceStoreTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions OpenEdX/DI/NetworkAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading