diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index fbebdd0c9..25fe475dd 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -158,6 +158,7 @@ 1801F1467ABCEA080419E150 /* preview_avatar_user.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 87FC42213E86E8182CFD3A49 /* preview_avatar_user.jpg */; }; 182D532B736178A1DED9F76E /* ReportRoomScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11FCAE847556719BBE7A0882 /* ReportRoomScreenModels.swift */; }; 18386B777FDA74E4B3282D4F /* TimelineItemThreadSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C6A082F2B2A15E1B9BE280 /* TimelineItemThreadSummary.swift */; }; + 1866A34DBAD0F730058CB3C3 /* ClassicAppMediaLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AC3F28DECDF8665E8EBC76E /* ClassicAppMediaLoaderTests.swift */; }; 18867F4F1C8991EEC56EA932 /* UTType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897DF5E9A70CE05A632FC8AF /* UTType.swift */; }; 18978C9438206828C1D5AF2A /* test_animated_image.gif in Resources */ = {isa = PBXBuildFile; fileRef = 53FD6D3D38F556CEAA280C58 /* test_animated_image.gif */; }; 18E3786918486D4C9726BC84 /* FormButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FBFC09F9DAFF1E4BA97849 /* FormButtonStyles.swift */; }; @@ -254,6 +255,7 @@ 2955F4C160CFD7794D819C64 /* EffectsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024F7398C5FC12586FB10E9D /* EffectsScene.swift */; }; 298F9EC30E918F12AB7F1EE8 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81F0325E252B057FAEEE1B2D /* TypingIndicatorView.swift */; }; 29EE1791E0AFA1ABB7F23D2F /* GZIP in Frameworks */ = {isa = PBXBuildFile; productRef = 1BCD21310B997A6837B854D6 /* GZIP */; }; + 2A4F8B76E1F99DE95D5867E6 /* ClassicAppManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 633BAD3D9BB44B2AED7CBB93 /* ClassicAppManagerMock.swift */; }; 2A56B00B070F83E0FE571193 /* TimelineMediaPreviewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B18A454132A5A5247802821E /* TimelineMediaPreviewDataSource.swift */; }; 2A61D2B4A225332CECA3B937 /* ReportRoomScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20403084A320D588ACED200 /* ReportRoomScreenViewModelProtocol.swift */; }; 2AAB2A77F1762A2648078A30 /* InteractiveQuickLook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */; }; @@ -615,6 +617,7 @@ 68C3AF257678F6E7BB238C3F /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FD22AEFFA20065494ED2333 /* AppAppearance.swift */; }; 695825D20A761C678809345D /* MessageForwardingScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52135BD9E0E7A091688F627A /* MessageForwardingScreenModels.swift */; }; 695BE6A2337A634F48B5DBC8 /* RoomMembersFlowCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC666DAE98245269775329B2 /* RoomMembersFlowCoordinatorTests.swift */; }; + 696D34FAA9A4E00435DDAF39 /* ClassicAppMediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D57D1C26306256332BA070F /* ClassicAppMediaLoader.swift */; }; 69A9B430397C15075D86193F /* UserPropertiesExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66AFD800AF033D8B0D11191A /* UserPropertiesExt.swift */; }; 69B3C6010B42010F591FC3CB /* RoomRolesAndPermissionsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1AF829F12FDC99717082D9 /* RoomRolesAndPermissionsScreenViewModel.swift */; }; 69B9CC733A880E1BB097C113 /* LinkNewDeviceScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D09290C6791D6EF04F569E /* LinkNewDeviceScreenModels.swift */; }; @@ -1224,6 +1227,7 @@ D10BA4F041DC58580A440A32 /* RoomRolesAndPermissionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B1DC3B3FB40A7F4AE9B7BF /* RoomRolesAndPermissionsScreen.swift */; }; D12F440F7973F1489F61389D /* NotificationSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F64447FF544298A6A3BEF85 /* NotificationSettingsScreenModels.swift */; }; D150D6E96CA6CA09FA50E13C /* LabsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF17EFB2833B4CE5C06E7F8 /* LabsScreenCoordinator.swift */; }; + D16B3134A7FF4FBA0749014A /* AuthenticationClassicAppAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C45578F406698388B97C07 /* AuthenticationClassicAppAccountView.swift */; }; D18B70975644C24F60656C0D /* KnockRequestProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07851F4EA81AA3339806A7B /* KnockRequestProxyProtocol.swift */; }; D19A748E95E2FAB2940570F0 /* CallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4103AB4340F2974D690A12A /* CallScreen.swift */; }; D2048FD56760BDABA3DB5FC2 /* AppLockServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EAAB54C6CE91D64B69A9F8 /* AppLockServiceProtocol.swift */; }; @@ -2015,6 +2019,7 @@ 4A2B5274C1D3D2999D643786 /* EncryptionResetPasswordScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreenViewModelProtocol.swift; sourceTree = ""; }; 4A5B4CD611DE7E94F5BA87B2 /* AppLockTimerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockTimerTests.swift; sourceTree = ""; }; 4AB29A2D95D3469B5F016655 /* SecureBackupControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupControllerMock.swift; sourceTree = ""; }; + 4AC3F28DECDF8665E8EBC76E /* ClassicAppMediaLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppMediaLoaderTests.swift; sourceTree = ""; }; 4B1F71AC585827E6C416C15A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 4B2B564CA6570E1487A7C7CC /* SpaceRoomListProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceRoomListProxy.swift; sourceTree = ""; }; 4B2D4EEBE8C098BBADD10939 /* SecureBackupKeyBackupScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupKeyBackupScreenCoordinator.swift; sourceTree = ""; }; @@ -2026,6 +2031,7 @@ 4CDDDDD9FE1A699D23A5E096 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = ""; }; 4CF17EFB2833B4CE5C06E7F8 /* LabsScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenCoordinator.swift; sourceTree = ""; }; 4D3A7375AB22721C436EB056 /* ComposerToolbarModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerToolbarModels.swift; sourceTree = ""; }; + 4D57D1C26306256332BA070F /* ClassicAppMediaLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppMediaLoader.swift; sourceTree = ""; }; 4D635709C1D6D37C225AD40E /* RoomPowerLevelProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPowerLevelProxyProtocol.swift; sourceTree = ""; }; 4E2245243369B99216C7D84E /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 4E600B315B920B9687F8EE1B /* ComposerDraftServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerDraftServiceMock.swift; sourceTree = ""; }; @@ -2133,6 +2139,7 @@ 62B07B296D7A9D2F09120853 /* OrderedSet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderedSet.swift; sourceTree = ""; }; 62EACAFB3F3E017060F9F1C5 /* TimelineProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProviderMock.swift; sourceTree = ""; }; 633924B26ACCD29C18BEF4E8 /* DeactivateAccountScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeactivateAccountScreenViewModelProtocol.swift; sourceTree = ""; }; + 633BAD3D9BB44B2AED7CBB93 /* ClassicAppManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppManagerMock.swift; sourceTree = ""; }; 638790D3F915F0909315C47A /* PollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollView.swift; sourceTree = ""; }; 638A81B97D51591D0FCFA598 /* InteractiveQuickLook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveQuickLook.swift; sourceTree = ""; }; 63E8A1E8EE094F570573B6E8 /* RoomDetailsScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsScreenViewModelProtocol.swift; sourceTree = ""; }; @@ -2497,6 +2504,7 @@ A4D9DF4F2DF3507F99B5B97B /* LabsScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabsScreenViewModel.swift; sourceTree = ""; }; A54AAF72E821B4084B7E4298 /* PinnedEventsTimelineFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedEventsTimelineFlowCoordinator.swift; sourceTree = ""; }; A566F8F2C27B99E1FB80C69B /* JoinRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JoinRule.swift; sourceTree = ""; }; + A5C45578F406698388B97C07 /* AuthenticationClassicAppAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationClassicAppAccountView.swift; sourceTree = ""; }; A6B19D10B102956066AF117B /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; A6B891A6DA826E2461DBB40F /* PHGPostHogConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHGPostHogConfiguration.swift; sourceTree = ""; }; A6C11AD9813045E44F950410 /* ElementCallWidgetDriverProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallWidgetDriverProtocol.swift; sourceTree = ""; }; @@ -3708,6 +3716,7 @@ 0554FEA301486A8CFA475D5A /* AuthenticationClientBuilderFactoryMock.swift */, 9FD7E851E2BA8C5A8D284B2A /* BannedRoomProxyMock.swift */, 8F7FC9580CABF797A2E6213A /* BugReportServiceMock.swift */, + 633BAD3D9BB44B2AED7CBB93 /* ClassicAppManagerMock.swift */, E2F96CCBEAAA7F2185BFA354 /* ClientProxyMock.swift */, 4E600B315B920B9687F8EE1B /* ComposerDraftServiceMock.swift */, 86E1BAA7232081635662A83F /* CXProviderMock.swift */, @@ -4363,6 +4372,7 @@ 98F4DFFFC62187D9A4D2030D /* ClassicAppAccountManager.swift */, 384A744714571BAF138C6B86 /* ClassicAppAES.swift */, 39C78D5FF15343724902EB20 /* ClassicAppManager.swift */, + 4D57D1C26306256332BA070F /* ClassicAppMediaLoader.swift */, 74240BE69DACAD01AA670730 /* ClassicAppMXAccount.swift */, ); path = ClassicApp; @@ -4834,6 +4844,7 @@ EFFD3200F9960D4996159F10 /* BugReportServiceTests.swift */, 0328F54E0C3AAEDDF3E05D9D /* ChatsTabFlowCoordinatorTests.swift */, 80935ADC7ED867226225F965 /* ClassicAppAccountManagerTests.swift */, + 4AC3F28DECDF8665E8EBC76E /* ClassicAppMediaLoaderTests.swift */, D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */, CA29952595B804DA221A0C1D /* ComposerToolbarViewModelTests.swift */, 69D42EE0102D2857933625DD /* CreateRoomViewModelTests.swift */, @@ -5705,6 +5716,7 @@ A18A0DA2E598A7E76C50E53D /* View */ = { isa = PBXGroup; children = ( + A5C45578F406698388B97C07 /* AuthenticationClassicAppAccountView.swift */, D2B0E0A2F16603E7C827C295 /* AuthenticationClassicAppBackupInstructionsView.swift */, 98784280D98C852727BE0111 /* AuthenticationStartLogo.swift */, A768CA51A59B8A5D8C8FD599 /* AuthenticationStartScreen.swift */, @@ -7765,6 +7777,7 @@ 7F61F9ACD5EC9E845EF3EFBF /* BugReportServiceTests.swift in Sources */, 4BD5AB54A6982CF19F5CC7C4 /* ChatsTabFlowCoordinatorTests.swift in Sources */, 1F1BCEE81056FD9F344F3B0E /* ClassicAppAccountManagerTests.swift in Sources */, + 1866A34DBAD0F730058CB3C3 /* ClassicAppMediaLoaderTests.swift in Sources */, B5321A1F5B26A0F3EC54909E /* CollapsibleFlowLayoutTests.swift in Sources */, 3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */, 0C932A5158C1D0604DFC5750 /* ComposerToolbarViewModelTests.swift in Sources */, @@ -8046,6 +8059,7 @@ 88F348E2CB14FF71CBBB665D /* AudioRoomTimelineItemContent.swift in Sources */, 7BD2123144A32F082CECC108 /* AudioRoomTimelineView.swift in Sources */, 9278EC51D24E57445B290521 /* AudioSessionProtocol.swift in Sources */, + D16B3134A7FF4FBA0749014A /* AuthenticationClassicAppAccountView.swift in Sources */, 90C683C87BF6D39419402E5B /* AuthenticationClassicAppBackupInstructionsView.swift in Sources */, A51C65E5A3C9F2464A91A380 /* AuthenticationClientBuilderFactoryMock.swift in Sources */, AE066FC93E7B707C826B335A /* AuthenticationClientFactory.swift in Sources */, @@ -8115,6 +8129,8 @@ F58BF3BD3233A03F013816E4 /* ClassicAppAccountManager.swift in Sources */, 2C289BECCCBEDBA48DC9AC67 /* ClassicAppMXAccount.swift in Sources */, E8BBCFF3B1380F56C73690BC /* ClassicAppManager.swift in Sources */, + 2A4F8B76E1F99DE95D5867E6 /* ClassicAppManagerMock.swift in Sources */, + 696D34FAA9A4E00435DDAF39 /* ClassicAppMediaLoader.swift in Sources */, A52090A4FE0DB826578DFC03 /* Client.swift in Sources */, C80E06ED97CE52704A46C148 /* ClientBuilder.swift in Sources */, 87CEA3E07B602705BC2D2A20 /* ClientBuilderHook.swift in Sources */, diff --git a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift index 241516eee..d5a913525 100644 --- a/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/AuthenticationFlowCoordinator.swift @@ -263,6 +263,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol { provisioningParameters: provisioningParameters, isBugReportServiceEnabled: bugReportService.isEnabled, appSettings: appSettings, + mediaProvider: nil, // Currently unused. userIndicatorController: userIndicatorController) let coordinator = AuthenticationStartScreenCoordinator(parameters: parameters) diff --git a/ElementX/Sources/Mocks/ClassicAppManagerMock.swift b/ElementX/Sources/Mocks/ClassicAppManagerMock.swift new file mode 100644 index 000000000..e02b7e957 --- /dev/null +++ b/ElementX/Sources/Mocks/ClassicAppManagerMock.swift @@ -0,0 +1,41 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Foundation +import MatrixRustSDK + +extension ClassicAppManagerMock { + struct Configuration { } + + convenience init(_ configuration: Configuration) { + self.init() + } +} + +extension ClassicAppAccount { + static var mockAlice: ClassicAppAccount { + ClassicAppAccount(userID: "@alice:matrix.org", + displayName: "Alice", + avatarURL: nil, + serverName: "matrix.org", + homeserverURL: "https://matrix-client.matrix.org/", + cryptoStoreURL: .cachesDirectory, + cryptoStorePassphrase: "1234567890", + accessToken: "accessToken") + } + + static var mockDan: ClassicAppAccount { + ClassicAppAccount(userID: "@dan:matrix.org", + displayName: "Dan", + avatarURL: .mockMXCUserAvatar, + serverName: "matrix.org", + homeserverURL: "https://matrix-client.matrix.org/", + cryptoStoreURL: .cachesDirectory, + cryptoStorePassphrase: "1234567890", + accessToken: "accessToken") + } +} diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 79df81060..1d8b34311 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2239,6 +2239,77 @@ class CXProviderMock: CXProviderProtocol, @unchecked Sendable { reportCallWithEndedAtReasonClosure?(uuid, endedAt, reason) } } +class ClassicAppManagerMock: ClassicAppManagerProtocol, @unchecked Sendable { + + //MARK: - loadAccounts + + var loadAccountsThrowableError: Error? + var loadAccountsUnderlyingCallsCount = 0 + var loadAccountsCallsCount: Int { + get { + if Thread.isMainThread { + return loadAccountsUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = loadAccountsUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + loadAccountsUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + loadAccountsUnderlyingCallsCount = newValue + } + } + } + } + var loadAccountsCalled: Bool { + return loadAccountsCallsCount > 0 + } + + var loadAccountsUnderlyingReturnValue: [ClassicAppAccount]! + var loadAccountsReturnValue: [ClassicAppAccount]! { + get { + if Thread.isMainThread { + return loadAccountsUnderlyingReturnValue + } else { + var returnValue: [ClassicAppAccount]? = nil + DispatchQueue.main.sync { + returnValue = loadAccountsUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + loadAccountsUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + loadAccountsUnderlyingReturnValue = newValue + } + } + } + } + var loadAccountsClosure: (() throws -> [ClassicAppAccount])? + + func loadAccounts() throws -> [ClassicAppAccount] { + if let error = loadAccountsThrowableError { + throw error + } + loadAccountsCallsCount += 1 + if let loadAccountsClosure = loadAccountsClosure { + return try loadAccountsClosure() + } else { + return loadAccountsReturnValue + } + } +} class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { var actionsPublisher: AnyPublisher { get { return underlyingActionsPublisher } diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 1ebbefb52..a368c184f 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -86,6 +86,7 @@ enum UserAvatarSizeOnScreen { case threadList case threadSummary case map + case classicAppAccount var value: CGFloat { switch self { @@ -115,7 +116,7 @@ enum UserAvatarSizeOnScreen { 64 case .dmDetails: 75 - case .memberDetails, .editUserDetails: + case .memberDetails, .editUserDetails, .classicAppAccount: 96 } } diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift index a1c56d284..bd756de0e 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenCoordinator.swift @@ -14,9 +14,20 @@ struct AuthenticationStartScreenParameters { let provisioningParameters: AccountProvisioningParameters? let isBugReportServiceEnabled: Bool let appSettings: AppSettings + let mediaProvider: MediaProviderProtocol? let userIndicatorController: UserIndicatorControllerProtocol } +enum AuthenticationStartScreenCoordinatorAction { + case loginWithQR + case login + case register + case reportProblem + + case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow) + case loginDirectlyWithPassword(loginHint: String?) +} + final class AuthenticationStartScreenCoordinator: CoordinatorProtocol { private var viewModel: AuthenticationStartScreenViewModelProtocol private let actionsSubject: PassthroughSubject = .init() @@ -31,6 +42,7 @@ final class AuthenticationStartScreenCoordinator: CoordinatorProtocol { provisioningParameters: parameters.provisioningParameters, isBugReportServiceEnabled: parameters.isBugReportServiceEnabled, appSettings: parameters.appSettings, + mediaProvider: parameters.mediaProvider, userIndicatorController: parameters.userIndicatorController) } diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift index 57607cec4..91f147a06 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenModels.swift @@ -8,18 +8,6 @@ import SwiftUI -// MARK: - Coordinator - -enum AuthenticationStartScreenCoordinatorAction { - case loginWithQR - case login - case register - case reportProblem - - case loginDirectlyWithOIDC(data: OIDCAuthorizationDataProxy, window: UIWindow) - case loginDirectlyWithPassword(loginHint: String?) -} - enum AuthenticationStartScreenViewModelAction: Equatable { case loginWithQR case login @@ -70,4 +58,8 @@ enum AuthenticationStartScreenViewAction { case login case register case reportProblem + + case continueWithClassic(ClassicAppAccount) + case otherOptions(ClassicAppAccount) + case closeOtherOptions(ClassicAppAccount) } diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift index 581146227..b84072838 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift @@ -24,11 +24,12 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType var actions: AnyPublisher { actionsSubject.eraseToAnyPublisher() } - + init(authenticationService: AuthenticationServiceProtocol, provisioningParameters: AccountProvisioningParameters?, isBugReportServiceEnabled: Bool, appSettings: AppSettings, + mediaProvider: MediaProviderProtocol?, userIndicatorController: UserIndicatorControllerProtocol) { self.authenticationService = authenticationService self.provisioningParameters = provisioningParameters @@ -59,9 +60,9 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType hideBrandChrome: appSettings.hideBrandChrome) } - super.init(initialViewState: initialViewState) + super.init(initialViewState: initialViewState, mediaProvider: mediaProvider) } - + override func process(viewAction: AuthenticationStartScreenViewAction) { switch viewAction { case .updateWindow(let window): @@ -77,6 +78,8 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType if canReportProblem { actionsSubject.send(.reportProblem) } + case .continueWithClassic, .otherOptions, .closeOtherOptions: + break // To follow. } } diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppAccountView.swift b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppAccountView.swift new file mode 100644 index 000000000..b460bfca9 --- /dev/null +++ b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppAccountView.swift @@ -0,0 +1,120 @@ +// +// Copyright 2025 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +struct AuthenticationClassicAppAccountView: View { + @Bindable var context: AuthenticationStartScreenViewModel.Context + + let classicAppAccount: ClassicAppAccount + + var body: some View { + FullscreenDialog(topPadding: 25, background: .gradient) { + VStack(spacing: 38) { + header + .padding(.bottom, 20) + + profile + + buttons + } + } bottomContent: { + // Buttons are intentionally shown inline on this screen. + } + .navigationBarTitleDisplayMode(.inline) + .alert(item: $context.alertInfo) + .introspect(.window, on: .supportedVersions) { window in + context.send(viewAction: .updateWindow(window)) + } + } + + var header: some View { + VStack(spacing: 8) { + AuthenticationStartLogo(size: 54, hideBrandChrome: false, isOnGradient: false) + + Text(L10n.screenOnboardingWelcomeTitle) + .font(.compound.headingMDBold) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) + } + } + + var profile: some View { + VStack(spacing: 16) { + LoadableAvatarImage(url: classicAppAccount.avatarURL, + name: classicAppAccount.displayName, + contentID: classicAppAccount.userID, + avatarSize: .user(on: .classicAppAccount), + mediaProvider: context.mediaProvider) + + VStack(spacing: 0) { + Text(L10n.screenOnboardingWelcomeBack) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .multilineTextAlignment(.center) + + Text(classicAppAccount.displayableName) + .font(.compound.headingLGBold) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) + } + + Text(classicAppAccount.userID) + .font(.compound.bodyLGSemibold) + .foregroundStyle(.compound.textPrimary) + .multilineTextAlignment(.center) + } + } + + var buttons: some View { + VStack(spacing: 16) { + Button(L10n.actionContinue) { + context.send(viewAction: .continueWithClassic(classicAppAccount)) + } + .buttonStyle(.compound(.primary)) + + Button(L10n.commonOtherOptions) { + context.send(viewAction: .otherOptions(classicAppAccount)) + } + .buttonStyle(.compound(.secondary)) + } + } +} + +private extension ClassicAppAccount { + var displayableName: String { + if let displayName, !displayName.isEmpty { + displayName + } else if let localPart = userID.dropFirst().split(separator: ":").first, !localPart.isEmpty { + String(localPart) + } else { + userID + } + } +} + +// MARK: - Previews + +struct AuthenticationClassicAppAccountView_Previews: PreviewProvider { // Not Testable – snapshots generated by main screen. + static let viewModel = makeViewModel() + + static var previews: some View { + ElementNavigationStack { + AuthenticationClassicAppAccountView(context: viewModel.context, classicAppAccount: .mockDan) + } + } + + static func makeViewModel() -> AuthenticationStartScreenViewModel { + AuthenticationStartScreenViewModel(authenticationService: AuthenticationService.mock, + provisioningParameters: nil, + isBugReportServiceEnabled: false, + appSettings: ServiceLocator.shared.settings, + mediaProvider: MediaProviderMock(configuration: .init()), + userIndicatorController: UserIndicatorControllerMock()) + } +} diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppBackupInstructionsView.swift b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppBackupInstructionsView.swift index 93b065845..64a0a28de 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppBackupInstructionsView.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationClassicAppBackupInstructionsView.swift @@ -69,6 +69,7 @@ struct AuthenticationClassicAppBackupInstructionsView_Previews: PreviewProvider, provisioningParameters: nil, isBugReportServiceEnabled: false, appSettings: ServiceLocator.shared.settings, + mediaProvider: MediaProviderMock(configuration: .init()), userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift index 946a96493..440c2f851 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/View/AuthenticationStartScreen.swift @@ -152,6 +152,7 @@ struct AuthenticationStartScreen_Previews: PreviewProvider, TestablePreview { provisioningParameters: provisionedServerName.map { .init(accountProvider: $0, loginHint: nil) }, isBugReportServiceEnabled: true, appSettings: ServiceLocator.shared.settings, + mediaProvider: MediaProviderMock(configuration: .init()), userIndicatorController: UserIndicatorControllerMock()) } } diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift index 72120d4a8..3938438c3 100644 --- a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift @@ -68,10 +68,12 @@ class ClassicAppAccountManager { return ClassicAppAccount(userID: userID, displayName: user?.displayName, - avatarURL: user?.avatarURL.flatMap(URL.init(string:)), + avatarURL: user?.avatarURL, serverName: serverName, + homeserverURL: mxAccount.homeserverURL, cryptoStoreURL: cryptoStoreURL(for: userID), - cryptoStorePassphrase: cryptoStorePassphrase) + cryptoStorePassphrase: cryptoStorePassphrase.base64EncodedString(), + accessToken: mxAccount.accessToken) } private func loadUser(for mxAccount: ClassicAppMXAccount) -> ClassicAppMXUser? { diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift index 7c3af36c9..b9b965b04 100644 --- a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift @@ -7,13 +7,23 @@ import Foundation -struct ClassicAppAccount: Equatable { +struct ClassicAppAccount: Equatable, CustomStringConvertible { let userID: String let displayName: String? let avatarURL: URL? + let serverName: String + let homeserverURL: URL + let cryptoStoreURL: URL - let cryptoStorePassphrase: Data + let cryptoStorePassphrase: String + + let accessToken: String // For avatar loading and key backup detection. + + /// Custom `CustomStringConvertible` without the access token. + var description: String { + "ClassicAppAccount(userID: \(userID), homeserverURL: \(homeserverURL))" + } } // MARK: NSCoding Types @@ -21,8 +31,10 @@ struct ClassicAppAccount: Equatable { final class ClassicAppMXAccount: NSObject, NSCoding { /// The obtained user ID. var userID: String + /// The access token to create a MXRestClient. + var accessToken: String /// The homeserver url (ex: "https://matrix.org"). - var homeserver: String? + var homeserverURL: URL /// Disable the account without logging out (NO by default). /// @@ -37,6 +49,11 @@ final class ClassicAppMXAccount: NSObject, NSCoding { !isDisabled && !isSoftLogout } + /// Override the existing `CustomStringConvertible` conformance. + override var description: String { + "ClassicAppMXAccount(userID: \(userID), homeserverURL: \(homeserverURL), isDisabled: \(isDisabled), isSoftLogout: \(isSoftLogout))" + } + // MARK: NSCoding enum Keys { @@ -64,12 +81,15 @@ final class ClassicAppMXAccount: NSObject, NSCoding { required init?(coder: NSCoder) { guard let userID = coder.decodeObject(forKey: Keys.userID) as? String, - let homeserver = coder.decodeObject(forKey: Keys.homeserverURL) as? String else { + let accessToken = coder.decodeObject(forKey: Keys.accessToken) as? String, + let homeserver = coder.decodeObject(forKey: Keys.homeserverURL) as? String, + let homeserverURL = URL(string: homeserver) else { return nil } self.userID = userID - self.homeserver = homeserver + self.accessToken = accessToken + self.homeserverURL = homeserverURL isDisabled = coder.decodeBool(forKey: Keys.isDisabled) isSoftLogout = coder.decodeBool(forKey: Keys.isSoftLogout) @@ -89,7 +109,7 @@ final class ClassicAppMXUser: NSObject, NSCoding { /// The user display name. let displayName: String? /// The url of the user of the avatar. - let avatarURL: String? + let avatarURL: URL? // MARK: NSCoding @@ -110,7 +130,9 @@ final class ClassicAppMXUser: NSObject, NSCoding { self.userID = userID displayName = aDecoder.decodeObject(forKey: Keys.displayName) as? String - avatarURL = aDecoder.decodeObject(forKey: Keys.avatarURL) as? String + + let avatarURLString = aDecoder.decodeObject(forKey: Keys.avatarURL) as? String + avatarURL = avatarURLString.flatMap { URL(string: $0) } super.init() } diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift index c8d849727..0cbb9381a 100644 --- a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift @@ -9,6 +9,7 @@ import Foundation import KeychainAccess import MatrixRustSDK +// sourcery: AutoMockable protocol ClassicAppManagerProtocol { func loadAccounts() throws -> [ClassicAppAccount] } diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMediaLoader.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMediaLoader.swift new file mode 100644 index 000000000..2a4cd4a38 --- /dev/null +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMediaLoader.swift @@ -0,0 +1,91 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +/// A media provider that can download a `ClassicAppAccount`'s avatar image. +class ClassicAppMediaLoader: MediaLoaderProtocol { + let classicAppAccount: ClassicAppAccount + let urlSession: URLSession + + init(classicAppAccount: ClassicAppAccount, urlSession: URLSession = .shared) { + self.classicAppAccount = classicAppAccount + self.urlSession = urlSession + } + + func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data { + try await loadMedia(source: source) + } + + func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data { + try await loadMedia(source: source, width: width, height: height) + } + + func loadMediaFileForSource(_ source: MediaSourceProxy, filename: String?) async throws -> MediaFileHandleProxy { + throw MediaLoaderError.notSupported // Not needed for LoadableImage + } + + // MARK: - Private + + private func loadMedia(source: MediaSourceProxy, width: UInt? = nil, height: UInt? = nil) async throws -> Data { + guard let mxcURL = source.url else { + MXLog.error("The provided media source is missing the URL") + throw MediaLoaderError.invalidURL + } + + guard let request = mediaURLRequest(from: mxcURL, width: width, height: height) else { + MXLog.error("Failed to construct media URL for source: \(mxcURL)") + throw MediaLoaderError.invalidURL + } + + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + MXLog.error("Unexpected response fetching ClassicAppAccount media: \(source.url?.absoluteString ?? "nil")") + throw MediaLoaderError.unexpectedResponse + } + + return data + } + + /// Constructs an authenticated `URLRequest` to download Matrix media from the homeserver. + /// + /// - When `width` and `height` are provided, converts `mxc://{serverName}/{mediaId}` to + /// `{homeserverURL}/_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}?width=…&height=…` + /// - Otherwise converts to `{homeserverURL}/_matrix/client/v1/media/download/{serverName}/{mediaId}` + /// + /// Sets the `Authorization: Bearer` header when an access token is provided. + private func mediaURLRequest(from mxcURL: URL, width: UInt?, height: UInt?) -> URLRequest? { + guard mxcURL.scheme == "mxc" else { return nil } + + let serverName = mxcURL.host() ?? "" + let mediaID = mxcURL.path().trimmingCharacters(in: CharacterSet(charactersIn: "/")) + + guard !serverName.isEmpty, !mediaID.isEmpty else { return nil } + + let isThumbnail = width != nil && height != nil + let endpoint = isThumbnail ? "_matrix/client/v1/media/thumbnail" : "_matrix/client/v1/media/download" + + var components = URLComponents(url: classicAppAccount.homeserverURL + .appending(path: endpoint) + .appending(path: serverName) + .appending(path: mediaID), resolvingAgainstBaseURL: false) + + if let width, let height { + components?.queryItems = [ + URLQueryItem(name: "width", value: String(width)), + URLQueryItem(name: "height", value: String(height)) + ] + } + + guard let url = components?.url else { return nil } + + var request = URLRequest(url: url) + request.setValue("Bearer \(classicAppAccount.accessToken)", forHTTPHeaderField: "Authorization") + return request + } +} diff --git a/ElementX/Sources/Services/Media/Provider/MediaLoaderProtocol.swift b/ElementX/Sources/Services/Media/Provider/MediaLoaderProtocol.swift index ba7f63efd..3d372f8b0 100644 --- a/ElementX/Sources/Services/Media/Provider/MediaLoaderProtocol.swift +++ b/ElementX/Sources/Services/Media/Provider/MediaLoaderProtocol.swift @@ -10,6 +10,9 @@ import Foundation enum MediaLoaderError: Error { case missingClient + case notSupported + case invalidURL + case unexpectedResponse } // sourcery: AutoMockable diff --git a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift index 380f5d3ae..64cb07a0a 100644 --- a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift +++ b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift @@ -160,6 +160,7 @@ final class AuthenticationStartScreenViewModelTests { provisioningParameters: provisioningParameters, isBugReportServiceEnabled: true, appSettings: appSettings, + mediaProvider: MediaProviderMock(configuration: .init()), userIndicatorController: UserIndicatorControllerMock()) // Add a fake window in order for the OIDC flow to continue diff --git a/UnitTests/Sources/ClassicAppAccountManagerTests.swift b/UnitTests/Sources/ClassicAppAccountManagerTests.swift index a75702b19..ac0f2812c 100644 --- a/UnitTests/Sources/ClassicAppAccountManagerTests.swift +++ b/UnitTests/Sources/ClassicAppAccountManagerTests.swift @@ -77,7 +77,9 @@ extension ClassicAppAccount { displayName: "Classic App Account", avatarURL: "mxc://matrix.org/LYIzLOiILkjQJCqsgzAOUirs", serverName: "matrix.org", + homeserverURL: "https://matrix-client.matrix.org", cryptoStoreURL: classicAppAccountManager.cryptoStoreURL(for: userID), - cryptoStorePassphrase: cryptoStorePassphrase) + cryptoStorePassphrase: cryptoStorePassphrase.base64EncodedString(), + accessToken: "mct_6luZquERViQxGSXqzdxDeMpQkEjHpk_ISvHO2") // Note: Deactivated account } } diff --git a/UnitTests/Sources/ClassicAppMediaLoaderTests.swift b/UnitTests/Sources/ClassicAppMediaLoaderTests.swift new file mode 100644 index 000000000..a0b6233c6 --- /dev/null +++ b/UnitTests/Sources/ClassicAppMediaLoaderTests.swift @@ -0,0 +1,181 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +@testable import ElementX +import Foundation +import Testing + +final class ClassicAppMediaLoaderTests { + let account: ClassicAppAccount + let mediaLoader: ClassicAppMediaLoader + let urlSession: URLSession + + init() throws { + account = ClassicAppAccount(userID: "@alice:matrix.org", + displayName: nil, + avatarURL: nil, + serverName: "matrix.org", + homeserverURL: "https://matrix-client.matrix.org", + cryptoStoreURL: .temporaryDirectory, + cryptoStorePassphrase: "", + accessToken: MockURLProtocol.accessToken) + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + urlSession = URLSession(configuration: configuration) + + mediaLoader = ClassicAppMediaLoader(classicAppAccount: account, urlSession: urlSession) + } + + // MARK: - Images + + @Test + func loadMediaContent() async throws { + let source = try MediaSourceProxy(url: MockURLProtocol.mxcURL, mimeType: nil) + let data = try await mediaLoader.loadMediaContentForSource(source) + #expect(data == MockURLProtocol.downloadData) + } + + @Test + func loadMediaContentWithInvalidToken() async throws { + let accountWithoutToken = ClassicAppAccount(userID: "@bob:matrix.org", + displayName: nil, + avatarURL: nil, + serverName: "matrix.org", + homeserverURL: "https://matrix-client.matrix.org", + cryptoStoreURL: .temporaryDirectory, + cryptoStorePassphrase: "", + accessToken: "wrongToken") + let loaderWithoutToken = ClassicAppMediaLoader(classicAppAccount: accountWithoutToken, urlSession: urlSession) + + let source = try MediaSourceProxy(url: MockURLProtocol.mxcURL, mimeType: nil) + await #expect(throws: MediaLoaderError.unexpectedResponse) { + try await loaderWithoutToken.loadMediaContentForSource(source) + } + } + + @Test + func loadMediaContentWith404() async throws { + let source = try MediaSourceProxy(url: MockURLProtocol.notFoundMXCURL, mimeType: nil) + await #expect(throws: MediaLoaderError.unexpectedResponse) { + try await mediaLoader.loadMediaContentForSource(source) + } + } + + // MARK: - Thumbnails + + @Test + func loadMediaThumbnail() async throws { + let source = try MediaSourceProxy(url: MockURLProtocol.mxcURL, mimeType: nil) + let data = try await mediaLoader.loadMediaThumbnailForSource(source, width: 100, height: 100) + #expect(data == MockURLProtocol.thumbnailData) + } + + @Test + func loadMediaThumbnailWith404() async throws { + let source = try MediaSourceProxy(url: MockURLProtocol.notFoundMXCURL, mimeType: nil) + await #expect(throws: MediaLoaderError.unexpectedResponse) { + try await mediaLoader.loadMediaThumbnailForSource(source, width: 100, height: 100) + } + } + + // MARK: - Files (unsupported) + + @Test + func loadMediaFileIsNotSupported() async throws { + let source = try MediaSourceProxy(url: MockURLProtocol.mxcURL, mimeType: nil) + await #expect(throws: MediaLoaderError.notSupported) { + try await mediaLoader.loadMediaFileForSource(source, filename: nil) + } + } + + // MARK: - URL construction + + @Test + func loadMediaContentBuildsCorrectDownloadURL() async throws { + let source = try MediaSourceProxy(url: MockURLProtocol.mxcURL, mimeType: nil) + let capturedRequest = try await mediaLoader.loadMediaContentForSource(source) + + // The download path must use the download endpoint with no query string. + #expect(MockURLProtocol.lastRequest?.url?.path() == "/_matrix/client/v1/media/download/matrix.org/testmediaid") + #expect(MockURLProtocol.lastRequest?.url?.query() == nil) + #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockURLProtocol.accessToken)") + _ = capturedRequest + } + + @Test + func loadMediaThumbnailBuildsCorrectThumbnailURL() async throws { + let source = try MediaSourceProxy(url: MockURLProtocol.mxcURL, mimeType: nil) + _ = try await mediaLoader.loadMediaThumbnailForSource(source, width: 320, height: 240) + + // The thumbnail path must use the thumbnail endpoint with width/height query items. + let url = try #require(MockURLProtocol.lastRequest?.url) + #expect(url.path() == "/_matrix/client/v1/media/thumbnail/matrix.org/testmediaid") + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + #expect(queryItems?.contains(URLQueryItem(name: "width", value: "320")) == true) + #expect(queryItems?.contains(URLQueryItem(name: "height", value: "240")) == true) + #expect(MockURLProtocol.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockURLProtocol.accessToken)") + } +} + +// MARK: - MockURLProtocol + +private class MockURLProtocol: URLProtocol { + /// The MXC URL whose media requests will be served successfully. + static let mxcURL: URL = "mxc://matrix.org/testmediaid" + /// The MXC URL whose media requests will return a 404. + static let notFoundMXCURL: URL = "mxc://matrix.org/notfound" + + /// The access token expected on all authenticated requests. + static let accessToken = "testAccessToken" + + /// The data returned for a full-size download of `mxcURL`. + static let downloadData = Data("download data".utf8) + /// The data returned for a thumbnail download of `mxcURL`. + static let thumbnailData = Data("thumbnail data".utf8) + + /// The last request handled, for URL/header inspection in tests. + static var lastRequest: URLRequest? + + /// Maps a URL path to a fixed `(statusCode, Data)` response. + private static let responses: [String: (Int, Data)] = [ + "/_matrix/client/v1/media/download/matrix.org/testmediaid": (200, downloadData), + "/_matrix/client/v1/media/thumbnail/matrix.org/testmediaid": (200, thumbnailData) + ] + + override func startLoading() { + MockURLProtocol.lastRequest = request + + guard let url = request.url else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + + let isAuthenticated = request.value(forHTTPHeaderField: "Authorization") == "Bearer \(MockURLProtocol.accessToken)" + let path = url.path() + let (statusCode, data) = isAuthenticated ? MockURLProtocol.responses[path] ?? (404, Data()) : (401, Data()) + + guard let response = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil) else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } +}