From a9dfc779fa551f02aa43dc91b9d18e2b46c02538 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:09:10 +0000 Subject: [PATCH] feat: add InstanceSessionManager (switch + logout) and route forced logout through it Implements infra-only -- no Presentation-layer files touched. logout no longer wipes a session's cached content, only its actual session state. - InstanceSessionManagerProtocol splits into two methods: - switchActiveInstance(to:) -- pure selection change, no teardown at all. Not called from anywhere yet (no picker UI exists), but built now. - logoutCurrentInstance() -- cancels in-flight downloads, unregisters this device's push token, clears storage (Keychain tokens/cached user), then clears the instance selection last. Deliberately does NOT clear CoreData rows or delete downloaded files -- a later re-login to the same instance resumes fast instead of re-fetching everything. - AppDelegate.didUserLogout now calls logoutCurrentInstance() instead of the three separate .clear()/.deleteAllProgress()/.deleteAll() calls it used to make inline. - New Core/Core/Configuration/PushTokenUnregistering.swift: a small protocol so logoutCurrentInstance() can call an app-target push-unregister step without depending on push/Firebase wiring (a later PR) landing first. InstanceSessionManager takes it as an optional dependency (nil today, so the step is simply skipped) rather than the reference implementation's required-protocol-with-default-no-op-extension shape, since nothing conforms to it yet and an optional avoids forcing a placeholder DI registration for a capability nobody can exercise yet. - CoreDataHandlerProtocol.clear(instanceKey:) / DatabaseManager's implementation are kept as built, but are no longer called by logout -- left in place as general per-instance infra (e.g. a future explicit "remove this instance's data" action), not dead-ended. - Wired InstanceSessionManagerProtocol through AppAssembly. - InstanceSessionManagerTests covers: switch does no teardown, logout tears down session state but not cached content, and logout still completes when no push conformance is wired. - CoreDataHandlerProtocol gains clear(instanceKey:), implemented in DatabaseManager via one NSBatchDeleteRequest per instanceKey-scoped entity (CDDownloadData, CDOfflineProgress, CDCourseItem, CDDownloadCoursePreview) against the shared merged store. --- Core/Core.xcodeproj/project.pbxproj | 12 +++ .../InstanceSessionManager.swift | 65 ++++++++++++++ .../PushTokenUnregistering.swift | 14 +++ Core/Core/Data/CoreDataHandlerProtocol.swift | 4 + .../InstanceSessionManagerTests.swift | 86 +++++++++++++++++++ OpenEdX/AppDelegate.swift | 10 +-- OpenEdX/DI/AppAssembly.swift | 9 ++ OpenEdX/Data/DatabaseManager.swift | 24 ++++++ 8 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 Core/Core/Configuration/InstanceSessionManager.swift create mode 100644 Core/Core/Configuration/PushTokenUnregistering.swift create mode 100644 Core/CoreTests/Configuration/InstanceSessionManagerTests.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 92812f366..d81e47bbb 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 */; }; BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */; }; BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */; }; BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */; }; @@ -227,6 +230,8 @@ 021D925628DCF12900ACC565 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; 022020452C11BB2200D15795 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; 02280F5A294B4E6F0032823A /* Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.swift; sourceTree = ""; }; + 3CC07EFD1E702E6E3D0847D7 /* InstanceSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSessionManager.swift; sourceTree = ""; }; + EA667E8817CE4A8910BE9EB3 /* PushTokenUnregistering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTokenUnregistering.swift; sourceTree = ""; }; 02286D152C106393005EEC8D /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; 022C64E329AE0191000F532B /* TextWithUrls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithUrls.swift; sourceTree = ""; }; 0231CDBD2922422D00032416 /* CSSInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSInjector.swift; sourceTree = ""; }; @@ -379,6 +384,7 @@ BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = ""; }; 02D8C6EECDB077F495200840 /* RequestInterceptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInterceptorTests.swift; sourceTree = ""; }; A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceStoreTests.swift; sourceTree = ""; }; + 08BCEBDF0BD7D711CA7205DE /* InstanceSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSessionManagerTests.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 = ""; }; BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleConfig.swift; sourceTree = ""; }; @@ -597,6 +603,8 @@ 0770DE1828D0847D006D8A5D /* BaseRouter.swift */, 0231CDBD2922422D00032416 /* CSSInjector.swift */, 02280F5A294B4E6F0032823A /* Connectivity.swift */, + 3CC07EFD1E702E6E3D0847D7 /* InstanceSessionManager.swift */, + EA667E8817CE4A8910BE9EB3 /* PushTokenUnregistering.swift */, ); path = Configuration; sourceTree = ""; @@ -952,6 +960,7 @@ BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */, A1B6ADB9B9C23899FFDCE9D5 /* InstanceStoreTests.swift */, 18582E5FF673AB654D47E8B7 /* InstancesConfigJSONTests.swift */, + 08BCEBDF0BD7D711CA7205DE /* InstanceSessionManagerTests.swift */, ); path = Configuration; sourceTree = ""; @@ -1183,6 +1192,7 @@ BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */, A79BEFA61FDAFB006FBD32F6 /* RequestInterceptorTests.swift in Sources */, EB3F7F969DFFD23CA3BE856C /* InstanceStoreTests.swift in Sources */, + ABD4C72FE7D0048D457D2937 /* InstanceSessionManagerTests.swift in Sources */, CE953A3B2CD0DA940023D667 /* CoreMocks.generated.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */, @@ -1348,6 +1358,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 */, diff --git a/Core/Core/Configuration/InstanceSessionManager.swift b/Core/Core/Configuration/InstanceSessionManager.swift new file mode 100644 index 000000000..73908ecd4 --- /dev/null +++ b/Core/Core/Configuration/InstanceSessionManager.swift @@ -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. + } +} diff --git a/Core/Core/Configuration/PushTokenUnregistering.swift b/Core/Core/Configuration/PushTokenUnregistering.swift new file mode 100644 index 000000000..4cca1d6e0 --- /dev/null +++ b/Core/Core/Configuration/PushTokenUnregistering.swift @@ -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 +} diff --git a/Core/Core/Data/CoreDataHandlerProtocol.swift b/Core/Core/Data/CoreDataHandlerProtocol.swift index 0ac2b3982..0cc9d10dd 100644 --- a/Core/Core/Data/CoreDataHandlerProtocol.swift +++ b/Core/Core/Data/CoreDataHandlerProtocol.swift @@ -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 } diff --git a/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift b/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift new file mode 100644 index 000000000..aa29441b6 --- /dev/null +++ b/Core/CoreTests/Configuration/InstanceSessionManagerTests.swift @@ -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) + } +} diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index f5ad8d591..aad86ab2d 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -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() diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 0ad13b31b..3a6aea1d0 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -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)! diff --git a/OpenEdX/Data/DatabaseManager.swift b/OpenEdX/Data/DatabaseManager.swift index d5a3e5a6a..0e5bde69a 100644 --- a/OpenEdX/Data/DatabaseManager.swift +++ b/OpenEdX/Data/DatabaseManager.swift @@ -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(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) + } + } + } + } }