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
12 changes: 12 additions & 0 deletions Core/Core.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@
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 */; };
7EE1DD11BB73A4C2A9032023 /* InstanceSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC07EFD1E702E6E3D0847D7 /* InstanceSessionManager.swift */; };
8B9199CE27AC5B6CBC254B66 /* PushTokenUnregistering.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA667E8817CE4A8910BE9EB3 /* PushTokenUnregistering.swift */; };
FC6CA744719A3BDF470AAF22 /* InstanceProvider+CurrentKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0251BC4E833C6CD650A467 /* InstanceProvider+CurrentKey.swift */; };
F9B869222683CEB16E39F8D1 /* InstanceNamespacedDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB9B2B3E3AD270F886237AAF /* InstanceNamespacedDefaults.swift */; };
064987932B4D69FF0071642A /* DragAndDropCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */; };
Expand Down Expand Up @@ -152,6 +154,7 @@
BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */; };
A79BEFA61FDAFB006FBD32F6 /* RequestInterceptorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D8C6EECDB077F495200840 /* RequestInterceptorTests.swift */; };
EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */; };
ABD4C72FE7D0048D457D2937 /* InstanceSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08BCEBDF0BD7D711CA7205DE /* InstanceSessionManagerTests.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 @@ -228,6 +231,8 @@
021D925628DCF12900ACC565 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = "<group>"; };
022020452C11BB2200D15795 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = "<group>"; };
02280F5A294B4E6F0032823A /* Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.swift; sourceTree = "<group>"; };
3CC07EFD1E702E6E3D0847D7 /* InstanceSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSessionManager.swift; sourceTree = "<group>"; };
EA667E8817CE4A8910BE9EB3 /* PushTokenUnregistering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTokenUnregistering.swift; sourceTree = "<group>"; };
02286D152C106393005EEC8D /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = "<group>"; };
022C64E329AE0191000F532B /* TextWithUrls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithUrls.swift; sourceTree = "<group>"; };
0231CDBD2922422D00032416 /* CSSInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSInjector.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -380,6 +385,7 @@
BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = "<group>"; };
02D8C6EECDB077F495200840 /* RequestInterceptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInterceptorTests.swift; sourceTree = "<group>"; };
A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStoreTests.swift; sourceTree = "<group>"; };
08BCEBDF0BD7D711CA7205DE /* InstanceSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSessionManagerTests.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 @@ -599,6 +605,8 @@
0770DE1828D0847D006D8A5D /* BaseRouter.swift */,
0231CDBD2922422D00032416 /* CSSInjector.swift */,
02280F5A294B4E6F0032823A /* Connectivity.swift */,
3CC07EFD1E702E6E3D0847D7 /* InstanceSessionManager.swift */,
EA667E8817CE4A8910BE9EB3 /* PushTokenUnregistering.swift */,
);
path = Configuration;
sourceTree = "<group>";
Expand Down Expand Up @@ -954,6 +962,7 @@
BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */,
A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */,
18582E5FF673AB654D47E8B7 /* InstancesConfigJSONTests.swift */,
08BCEBDF0BD7D711CA7205DE /* InstanceSessionManagerTests.swift */,
BDE56371521165CD47B36418 /* InstancesConfigTests.swift */,
);
path = Configuration;
Expand Down Expand Up @@ -1186,6 +1195,7 @@
BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */,
A79BEFA61FDAFB006FBD32F6 /* RequestInterceptorTests.swift in Sources */,
EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */,
ABD4C72FE7D0048D457D2937 /* InstanceSessionManagerTests.swift in Sources */,
9C33F22BA8733548621D5A97 /* InstancesConfigTests.swift in Sources */,
CE953A3B2CD0DA940023D667 /* CoreMocks.generated.swift in Sources */,
E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */,
Expand Down Expand Up @@ -1352,6 +1362,8 @@
0727878928D31734002E9142 /* User.swift in Sources */,
A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */,
02280F5B294B4E6F0032823A /* Connectivity.swift in Sources */,
7EE1DD11BB73A4C2A9032023 /* InstanceSessionManager.swift in Sources */,
8B9199CE27AC5B6CBC254B66 /* PushTokenUnregistering.swift in Sources */,
0254D1912BCD699F000CDE89 /* RefreshProgressView.swift in Sources */,
CED42FFC2D099F0400C7AD89 /* DownloadManagerMock.swift in Sources */,
02066B482906F73400F4307E /* PickerMenu.swift in Sources */,
Expand Down
65 changes: 65 additions & 0 deletions Core/Core/Configuration/InstanceSessionManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// InstanceSessionManager.swift
// Core
//
// Created by Rawan Matar on 20/09/2026.
//

import Foundation

/// @mockable
public protocol InstanceSessionManagerProtocol: Sendable {
/// Switches the active instance without tearing down the outgoing one's session.
/// Use `logoutCurrentInstance()` to actually end a session.
func switchActiveInstance(to instance: Instance) async

/// Logs out of the current instance: cancels downloads, unregisters push, clears
/// storage, then clears the selection. Leaves CoreData rows and downloaded files
/// in place so a later re-login resumes fast.
func logoutCurrentInstance() async
}

public final class InstanceSessionManager: InstanceSessionManagerProtocol {
private let instanceStore: InstanceStore
private let storage: CoreStorage
private let downloadManager: DownloadManagerProtocol
private let pushTokenUnregistrar: PushTokenUnregistering?

public init(
instanceStore: InstanceStore,
storage: CoreStorage,
downloadManager: DownloadManagerProtocol,
pushTokenUnregistrar: PushTokenUnregistering? = nil
) {
self.instanceStore = instanceStore
self.storage = storage
self.downloadManager = downloadManager
self.pushTokenUnregistrar = pushTokenUnregistrar
}

public func switchActiveInstance(to instance: Instance) async {
// No teardown -- storage, CoreData, and downloads stay untouched.
instanceStore.select(instance)

// No ThemeManager yet -- apply per-instance theme here once it exists.
}

public func logoutCurrentInstance() async {
// Cancel downloads before clearing storage.
try? await downloadManager.cancelAllDownloading()

// Unregister push while the access token is still valid.
await pushTokenUnregistrar?.unregisterCurrentPushToken()

// Clear tokens/cookies/cached user.
storage.clear()

// Clear selection last, so nothing observes a half-logged-out state.
instanceStore.select(nil)

// No ThemeManager yet -- reset theme here once it exists.

// CoreData rows and downloaded files are left in place on purpose --
// see CoreDataHandlerProtocol.clear(instanceKey:) for an explicit wipe.
}
}
14 changes: 14 additions & 0 deletions Core/Core/Configuration/PushTokenUnregistering.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// PushTokenUnregistering.swift
// Core
//
// Created by Rawan Matar on 20/09/2026.
//

import Foundation

/// @mockable
public protocol PushTokenUnregistering: Sendable {
/// Unregisters this device's push token from the active instance's backend.
func unregisterCurrentPushToken() async
}
4 changes: 4 additions & 0 deletions Core/Core/Data/CoreDataHandlerProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@
import Foundation

public protocol CoreDataHandlerProtocol: Sendable {
/// Destroys and rebuilds the whole persistent store -- every instance's data, gone.
func clear() async

/// Batch-deletes only `instanceKey`'s rows, leaving other instances' data intact.
func clear(instanceKey: String) async
}
86 changes: 86 additions & 0 deletions Core/CoreTests/Configuration/InstanceSessionManagerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// InstanceSessionManagerTests.swift
// Core
//
// Created by Rawan Matar on 20/09/2026.
//

import XCTest
import Foundation
@testable import Core

private final class PushTokenUnregisteringSpy: PushTokenUnregistering, @unchecked Sendable {
private(set) var unregisterCallCount = 0
func unregisterCurrentPushToken() async { unregisterCallCount += 1 }
}

@MainActor
final class InstanceSessionManagerTests: XCTestCase {

private let instanceA = Instance.mock(key: "instanceA", name: "Instance A")
private let instanceB = Instance.mock(key: "instanceB", name: "Instance B")

private var instanceStore: InstanceStore!
private var storage: CoreStorageMock!
private var downloadManager: DownloadManagerProtocolMock!
private var pushTokenUnregistrar: PushTokenUnregisteringSpy!
private var sut: InstanceSessionManager!

override func setUp() {
super.setUp()
let suiteName = "InstanceSessionManagerTests.\(UUID().uuidString)"
instanceStore = InstanceStore(userDefaults: UserDefaults(suiteName: suiteName)!) {
InstancesConfig(array: [])
}
storage = CoreStorageMock()
downloadManager = DownloadManagerProtocolMock()
pushTokenUnregistrar = PushTokenUnregisteringSpy()
sut = InstanceSessionManager(
instanceStore: instanceStore,
storage: storage,
downloadManager: downloadManager,
pushTokenUnregistrar: pushTokenUnregistrar
)
}

// Switching never tears anything down.
func testSwitchActiveInstance_DoesNotTearDownOutgoingInstance() async {
instanceStore.select(instanceA)

await sut.switchActiveInstance(to: instanceB)

XCTAssertEqual(downloadManager.cancelAllDownloadingCallCount, 0)
XCTAssertEqual(storage.clearCallCount, 0)
XCTAssertEqual(pushTokenUnregistrar.unregisterCallCount, 0)
XCTAssertEqual(instanceStore.currentInstance, instanceB)
}

// Logout clears session state but keeps downloaded files.
func testLogoutCurrentInstance_TearsDownSessionButKeepsCachedContent() async {
instanceStore.select(instanceA)

await sut.logoutCurrentInstance()

XCTAssertEqual(downloadManager.cancelAllDownloadingCallCount, 1)
XCTAssertEqual(downloadManager.deleteAllCallCount, 0)
XCTAssertEqual(pushTokenUnregistrar.unregisterCallCount, 1)
XCTAssertEqual(storage.clearCallCount, 1)
XCTAssertNil(instanceStore.currentInstance)
}

// Logout still works with no push conformance wired.
func testLogoutCurrentInstance_WithoutPushConformance_StillCompletesTeardown() async {
let sutWithoutPush = InstanceSessionManager(
instanceStore: instanceStore,
storage: storage,
downloadManager: downloadManager
)
instanceStore.select(instanceA)

await sutWithoutPush.logoutCurrentInstance()

XCTAssertEqual(downloadManager.cancelAllDownloadingCallCount, 1)
XCTAssertEqual(storage.clearCallCount, 1)
XCTAssertNil(instanceStore.currentInstance)
}
}
10 changes: 4 additions & 6 deletions OpenEdX/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
analyticsManager?.userLogout(force: true)

lastForceLogoutTime = Date().timeIntervalSince1970
Container.shared.resolve(CoreStorage.self)?.clear()


// Routes through InstanceSessionManager instead of duplicating cleanup here.
Task {
await Container.shared.resolve(CorePersistenceProtocol.self)?.deleteAllProgress()
await Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAll()
await Container.shared.resolve(CoreDataHandlerProtocol.self)?.clear()
await Container.shared.resolve(InstanceSessionManagerProtocol.self)?.logoutCurrentInstance()
window?.rootViewController = RouteController()
}
window?.rootViewController = RouteController()
}

UNUserNotificationCenter.current().removeAllDeliveredNotifications()
Expand Down
9 changes: 9 additions & 0 deletions OpenEdX/DI/AppAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ class AppAssembly: Assembly {
filePathProvider: r.resolve(InstanceFilePathProvider.self)!
)
}.inObjectScope(.container)

container.register(InstanceSessionManagerProtocol.self) { r in
InstanceSessionManager(
instanceStore: r.resolve(InstanceStore.self)!,
storage: r.resolve(CoreStorage.self)!,
downloadManager: r.resolve(DownloadManagerProtocol.self)!
// pushTokenUnregistrar: nil -- wired once push/Firebase lands.
)
}.inObjectScope(.container)

container.register(AuthorizationRouter.self) { r in
r.resolve(Router.self)!
Expand Down
24 changes: 24 additions & 0 deletions OpenEdX/Data/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,28 @@ final class DatabaseManager: CoreDataHandlerProtocol {
}
}
}

// instanceKey is optional on all four entities (PR-7); batch-delete each to clear just this instance's rows.
public func clear(instanceKey: String) async {
let entityNames = ["CDDownloadData", "CDOfflineProgress", "CDCourseItem", "CDDownloadCoursePreview"]
await getPersistentContainer().performBackgroundTask { context in
for entityName in entityNames {
let request = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
request.predicate = NSPredicate(format: "instanceKey == %@", instanceKey)
let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)
deleteRequest.resultType = .resultTypeObjectIDs
do {
let result = try context.execute(deleteRequest) as? NSBatchDeleteResult
if let objectIDs = result?.result as? [NSManagedObjectID] {
NSManagedObjectContext.mergeChanges(
fromRemoteContextSave: [NSDeletedObjectsKey: objectIDs],
into: [context]
)
}
} catch {
print("⛔️⛔️⛔️⛔️⛔️", entityName, error)
}
}
}
}
}
Loading