From 0bd72114b84006125837a334a9cb91a1fc5fd2cf Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 10 Mar 2026 13:54:14 +0000 Subject: [PATCH] Add tests for loading Classic accounts. Tidy up warnings and fix a few bugs that were revealed. --- .../ClassicAppFixtures/accountsV2 | Bin 0 -> 1552 bytes DevelopmentAssets/ClassicAppFixtures/users/94 | Bin 0 -> 700 bytes ElementX.xcodeproj/project.pbxproj | 40 ++++++--- .../Sources/Application/AppCoordinator.swift | 17 +--- .../ClassicApp/ClassicAppAccountManager.swift | 32 ++++--- .../ClassicApp/ClassicAppMXAccount.swift | 6 +- .../ClassicApp/ClassicAppManager.swift | 15 +++- .../ClassicAppAccountManagerTests.swift | 83 ++++++++++++++++++ UnitTests/SupportingFiles/target.yml | 4 +- 9 files changed, 156 insertions(+), 41 deletions(-) create mode 100644 DevelopmentAssets/ClassicAppFixtures/accountsV2 create mode 100644 DevelopmentAssets/ClassicAppFixtures/users/94 create mode 100644 UnitTests/Sources/ClassicAppAccountManagerTests.swift diff --git a/DevelopmentAssets/ClassicAppFixtures/accountsV2 b/DevelopmentAssets/ClassicAppFixtures/accountsV2 new file mode 100644 index 0000000000000000000000000000000000000000..2a0b3973349e35443c9434a7fa983dbc8efa8226 GIT binary patch literal 1552 zcmV+r2JiW{a})qIdh`k1_t@g9{`Vc(&G&6Xf?Jt2Asu!m@|7!O{I_>g4ViIKtpSug z287(*r-Xu%Ak+}o2x0KI*{mPTm2n4wd+cvvbrU4MLF`4Q-I0wkN|XBQ=dnI!vgrK~ z%U7^ZF$kSMQ{p$s3F46XERx^CoaNQZ$%vBCZEtt4byoQq$AY_a#+%e050 zeghY7>ExflMAHW+!T)y1awRl5E@XtQsfyentK3G%t7bkW_&7#XEmA(GQXF*MD`}_S z#O*v!{$`^L;)607_AX8CgH2y@6_b6xk#8FPzZ)u(j1 zgVNba_B68%HPFnswNAWeZd&K47Cjt!n9tUXYe>YD=BX9^Xy?0x;@CZEz`+Pm6Jjld zX@dT@s43>f#}aN~=JkGm*PGkkG97U0;nq-s@ZzFz`1$R~NUY$%$^Gq-wlGuc70`{L z3ujBxiR=mcYaNsS3po49*+JP;gE`Lt1tflK{;23IpfjTe(Or~2Qw3y-;nU;c{W z#UCLrLSu9TmfS*!7ovIkPzuRKs8mQN)KPMZ^XA&cAeRn&=D`X|_0&E5$y_f}39fHy z8$|Uc`{J<6dOD&Fk3dnI$Y&AX7HR&XY^BM=j>30@6Dslnbi6EFo_OB%h=;pVG;7Sx z0H_Y8oJ0UQ*3(dKz5P(C#Sz(59>RaK0YhJAn`lV4Z%|GGA$_C<9-xtDs!cP{N42+> zFyMj&{NSb%|2e>JBD`DMLAbMMjV=2(yQldRN`Sy?`xF`T z%Nrzjz_TvABaT;zfz}a#r4}Y|GgMUya0~x=ji^_oTv+1ce$37GRz0CzJEzjysmbI4 z`BhihO}UHmP{-_GCt`0+L(qmCPMuq4L+#=kZ*82WIRS=tsj!xRA!ejnEreJ^?KOK+ zdU@FsY-I=Oo#)aobf?#S%uSFsyjcRoDd|tq24#O)*e`pjS_H=dK8{|5+x?jmbFbo( zI;}P0K4;_ut%;kL5U_V?xoApAc~({6F-HGagfKcdF@wdRgscXD1Lj zZ5F)bLU7Z?{RzR+!fz7Pdu69c0bNG_-RTu&uv{NfcN!l2Wu||g|E=}y{kHY8Q+p$Dxirw CYzYeh literal 0 HcmV?d00001 diff --git a/DevelopmentAssets/ClassicAppFixtures/users/94 b/DevelopmentAssets/ClassicAppFixtures/users/94 new file mode 100644 index 0000000000000000000000000000000000000000..b1560d224dc29c90b28505478866168b0924957f GIT binary patch literal 700 zcmaix&rcIU6vy9Neii(|Lh%=hpsomrEfx{wWCah!ZX2>KZb5B^=>!&*-F0S4Sd7UY zlo(_D3p_|PF($^VSH0=Q#9tvEjqzekym{57ZNkOGclo}V`R2X%c{AtPmXJe3pHQi* zZ)j|4F|`%Oh2=U0jc~ePg{Pb*UC-3qxkW~$Xa`(>R8(6t#%1QSQnF@xA8F2U*OdW2 z+p%-c-oE~Ulf!w` zoH1Lngl7}qAnicHLZy<_!s3>-GKlA literal 0 HcmV?d00001 diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 2aa05693e..83c5e149c 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -193,6 +193,7 @@ 1D69E31913DF66426985909B /* EmojiPickerScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11151E78D6BB2B04A8FBD389 /* EmojiPickerScreenViewModelProtocol.swift */; }; 1DC227816777A2F3A19657E5 /* RoomDirectorySearchScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF71646898A2F720C5BFDF5 /* RoomDirectorySearchScreenViewModel.swift */; }; 1ECE584D2CFA0FEF0ADE458C /* TestablePreviewsDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA723686F23EF45E2B398FBC /* TestablePreviewsDictionary.swift */; }; + 1F1BCEE81056FD9F344F3B0E /* ClassicAppAccountManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80935ADC7ED867226225F965 /* ClassicAppAccountManagerTests.swift */; }; 1F3232BD368DF430AB433907 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = 07FEEEDB11543A7DED420F04 /* Compound */; }; 1FE593ECEC40A43789105D80 /* KeychainController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36CB905A2B9EC2C92A2DA7C /* KeychainController.swift */; }; 1FEC0A4EC6E6DF693C16B32A /* StringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */; }; @@ -847,6 +848,7 @@ 92720AB0DA9AB5EEF1DAF56B /* SecureBackupLogoutConfirmationScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC017C3CB6B0F7C63F460F2 /* SecureBackupLogoutConfirmationScreenViewModel.swift */; }; 9278EC51D24E57445B290521 /* AudioSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB284643AF7AB131E307DCE0 /* AudioSessionProtocol.swift */; }; 9295F1F5E04484E10780BCE8 /* CharacterSet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F8C01DEEA83903D45069BBD /* CharacterSet.swift */; }; + 92A0309B6F490B86C10B603B /* accountsV2 in Resources */ = {isa = PBXBuildFile; fileRef = 2A7BE2B89310058659E6F459 /* accountsV2 */; }; 92D9088B901CEBB1A99ECA4E /* RoomMemberProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36FD673E24FBFCFDF398716A /* RoomMemberProxyMock.swift */; }; 92FE657CDFAFE3031576EB43 /* LinkNewDeviceScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED096460D7F26F10168FA33B /* LinkNewDeviceScreenViewModelProtocol.swift */; }; 9312F5A17AE59A9E910C51D6 /* NotificationItemProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840182D7A61402D5947DE094 /* NotificationItemProxyMock.swift */; }; @@ -1061,6 +1063,7 @@ B796A25F282C0A340D1B9C12 /* ImageRoomTimelineItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B5EDCD05D50BA9B815C66C /* ImageRoomTimelineItemContent.swift */; }; B79E8AB83EBBDCD476D0362F /* PollFormScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D622EC7898469BB1D0881CDD /* PollFormScreen.swift */; }; B7C9E07F4F9CCC8DD7156A20 /* CallScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28146817C61423CACCF942F5 /* CallScreenModels.swift */; }; + B7DA25BCB68A38AD903AEB06 /* 94 in Resources */ = {isa = PBXBuildFile; fileRef = 1BC752C2A4606C4C2D1ADB41 /* 94 */; }; B7F58D6903F9D509EDAB9E4F /* MediaEventsTimelineScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7033218DA395B003F7AB29A2 /* MediaEventsTimelineScreenModels.swift */; }; B81840E45D8746A4692DA774 /* Tracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B574805B9812C111D6215D /* Tracing.swift */; }; B818580464CFB5400A3EF6AE /* TimelineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029D5701F80A9AF7167BB4D0 /* TimelineModels.swift */; }; @@ -1720,6 +1723,7 @@ 1B9D191A81FFB0C72CE73E77 /* RoomSelectionScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSelectionScreenModels.swift; sourceTree = ""; }; 1BA5A62DA4B543827FF82354 /* LAContextMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LAContextMock.swift; sourceTree = ""; }; 1BA8082E26C77A2C587B34B3 /* MockTimelineController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTimelineController.swift; sourceTree = ""; }; + 1BC752C2A4606C4C2D1ADB41 /* 94 */ = {isa = PBXFileReference; path = 94; sourceTree = ""; }; 1C21A715237F2B6D6E80998C /* SecureBackupControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupControllerProtocol.swift; sourceTree = ""; }; 1C25B6EBEB414431187D73B7 /* TimelineReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReplyView.swift; sourceTree = ""; }; 1C78111573987B1D79ED0868 /* LinkMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkMetadataProvider.swift; sourceTree = ""; }; @@ -1809,6 +1813,7 @@ 29A953B6C0C431DBF4DD00B4 /* RoomSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummary.swift; sourceTree = ""; }; 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModels.swift; sourceTree = ""; }; 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = ""; }; + 2A7BE2B89310058659E6F459 /* accountsV2 */ = {isa = PBXFileReference; path = accountsV2; sourceTree = ""; }; 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingHook.swift; sourceTree = ""; }; 2AB2C848BB9A7A9B618B7B89 /* TextBasedRoomTimelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextBasedRoomTimelineTests.swift; sourceTree = ""; }; 2ADF12A50186B75C68017B61 /* DeclineAndBlockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenViewModelTests.swift; sourceTree = ""; }; @@ -1987,7 +1992,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 = ""; }; - 4B1F71AC585827E6C416C15A /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; + 4B1F71AC585827E6C416C15A /* AppIcon.icon */ = {isa = PBXFileReference; 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 = ""; }; 4B41FABA2B0AEF4389986495 /* LoginMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginMode.swift; sourceTree = ""; }; @@ -2253,6 +2258,7 @@ 7FB2253D36E81E045E1CB432 /* Duration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Duration.swift; sourceTree = ""; }; 7FDF541AE914059942B575B4 /* IdentityConfirmationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenModels.swift; sourceTree = ""; }; 8063E65441E771200108C558 /* ReadReceiptsSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptsSummaryView.swift; sourceTree = ""; }; + 80935ADC7ED867226225F965 /* ClassicAppAccountManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicAppAccountManagerTests.swift; sourceTree = ""; }; 80E815FF3CC5E5A355E3A25E /* RoomMessageEventStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageEventStringBuilder.swift; sourceTree = ""; }; 80F04B12FA231E797B7151A8 /* LinkNewDeviceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkNewDeviceTests.swift; sourceTree = ""; }; 810133CF215075C285FC6F3A /* test_image.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = test_image.png; sourceTree = ""; }; @@ -3990,13 +3996,6 @@ path = SupportingFiles; sourceTree = ""; }; - 4001FC7CD65776AB3745245C /* ClassicAppAccountConfirmationScreen */ = { - isa = PBXGroup; - children = ( - ); - path = ClassicAppAccountConfirmationScreen; - sourceTree = ""; - }; 4044C040B64B9F077298C947 /* View */ = { isa = PBXGroup; children = ( @@ -4791,6 +4790,7 @@ 7EECE8B331CD169790EF284F /* BugReportScreenViewModelTests.swift */, EFFD3200F9960D4996159F10 /* BugReportServiceTests.swift */, 0328F54E0C3AAEDDF3E05D9D /* ChatsTabFlowCoordinatorTests.swift */, + 80935ADC7ED867226225F965 /* ClassicAppAccountManagerTests.swift */, D5EA0312A6262484AA393AC9 /* CompletionSuggestionServiceTests.swift */, CA29952595B804DA221A0C1D /* ComposerToolbarViewModelTests.swift */, 69D42EE0102D2857933625DD /* CreateRoomViewModelTests.swift */, @@ -5783,6 +5783,7 @@ A8002CB4F20B6282850A614C /* DevelopmentAssets */ = { isa = PBXGroup; children = ( + B3C5F21498CA4E4F255D596B /* ClassicAppFixtures */, DDAF2AD15C368A2809857B6A /* Media */, ); path = DevelopmentAssets; @@ -5847,6 +5848,14 @@ path = NSE; sourceTree = ""; }; + B0863C1541E34EBE22549125 /* users */ = { + isa = PBXGroup; + children = ( + 1BC752C2A4606C4C2D1ADB41 /* 94 */, + ); + path = users; + sourceTree = ""; + }; B1FC81662045E2369B0C4A0E /* Other */ = { isa = PBXGroup; children = ( @@ -5903,6 +5912,15 @@ path = SettingsScreen; sourceTree = ""; }; + B3C5F21498CA4E4F255D596B /* ClassicAppFixtures */ = { + isa = PBXGroup; + children = ( + 2A7BE2B89310058659E6F459 /* accountsV2 */, + B0863C1541E34EBE22549125 /* users */, + ); + path = ClassicAppFixtures; + sourceTree = ""; + }; B470504BE2DC95FAC94FDD79 /* ReadReceipts */ = { isa = PBXGroup; children = ( @@ -6618,7 +6636,6 @@ children = ( 92390F9FA98255440A6BF5F8 /* OIDCAuthenticationPresenter.swift */, 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */, - 4001FC7CD65776AB3745245C /* ClassicAppAccountConfirmationScreen */, 90F48FEF84016ED42A94BA24 /* LoginScreen */, BA1938A75D8C780F694CEB62 /* ServerConfirmationScreen */, 2D0D49B0533C4C2EB889BF3A /* ServerSelectionScreen */, @@ -7198,6 +7215,7 @@ }; }; buildConfigurationList = 7AE41FCCF9D1352E2770D1F9 /* Build configuration list for PBXProject "ElementX" */; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -7273,7 +7291,6 @@ C89CF7729E028671C5DC461E /* XCLocalSwiftPackageReference "compound-ios" */, ); preferredProjectObjectVersion = 77; - productRefGroup = 681566846AF307E9BA4C72C6 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -7364,6 +7381,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B7DA25BCB68A38AD903AEB06 /* 94 in Resources */, + 92A0309B6F490B86C10B603B /* accountsV2 in Resources */, F252C0EA49088801F4CA6006 /* landscape_test_image.jpg in Resources */, F4582042AA4225CC1E4B8A1E /* landscape_test_video.mov in Resources */, 8F3AD08F2E706AA60F1A1D4D /* portrait_test_image.jpg in Resources */, @@ -7667,6 +7686,7 @@ 1B2F9F368619FFF8C63C87CC /* BugReportScreenViewModelTests.swift in Sources */, 7F61F9ACD5EC9E845EF3EFBF /* BugReportServiceTests.swift in Sources */, 4BD5AB54A6982CF19F5CC7C4 /* ChatsTabFlowCoordinatorTests.swift in Sources */, + 1F1BCEE81056FD9F344F3B0E /* ClassicAppAccountManagerTests.swift in Sources */, B5321A1F5B26A0F3EC54909E /* CollapsibleFlowLayoutTests.swift in Sources */, 3A164187907DA43B7858F9EC /* CompletionSuggestionServiceTests.swift in Sources */, 0C932A5158C1D0604DFC5750 /* ComposerToolbarViewModelTests.swift in Sources */, diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index 71695c97a..56acfa7fc 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -605,7 +605,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg private func startAuthentication() { let encryptionKeyProvider = EncryptionKeyProvider() - let classicAppManager = makeClassicAppManager() + let classicAppManager = ClassicAppManager() let authenticationService = AuthenticationService(userSessionStore: userSessionStore, encryptionKeyProvider: encryptionKeyProvider, classicAppManager: classicAppManager, @@ -630,19 +630,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg } } - private func makeClassicAppManager() -> ClassicAppManagerProtocol? { - guard let classicAppGroupIdentifier = InfoPlistReader.main.classicAppGroupIdentifier, - let classicAppKeychainServiceIdentifier = InfoPlistReader.main.classicAppKeychainServiceIdentifier, - let classicAppKeychainAccessGroupIdentifier = InfoPlistReader.main.classicAppKeychainAccessGroupIdentifier else { - MXLog.info("Classic App IDs not set, manager disabled.") - return nil - } - - return ClassicAppManager(classicAppGroupIdentifier: classicAppGroupIdentifier, - classicAppKeychainServiceIdentifier: classicAppKeychainServiceIdentifier, - classicAppKeychainAccessGroupIdentifier: classicAppKeychainAccessGroupIdentifier) - } - private func runPostSessionSetupTasks() async { guard let userSession, let userSessionFlowCoordinator else { fatalError("User session not setup") @@ -675,7 +662,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg let authenticationService = AuthenticationService(userSessionStore: userSessionStore, encryptionKeyProvider: EncryptionKeyProvider(), - classicAppManager: makeClassicAppManager(), + classicAppManager: ClassicAppManager(), appSettings: appSettings, appHooks: appHooks) _ = await authenticationService.configure(for: userSession.clientProxy.homeserver, flow: .login) diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift index 7c5966a02..72120d4a8 100644 --- a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppAccountManager.swift @@ -25,16 +25,16 @@ class ClassicAppAccountManager { func loadAccounts() { MXLog.info("Loading accounts from Classic app.") + // First we need to load the accounts file, which contains an array of MXKAccounts (a super class of MXAccount). let accountFile = accountFile() if FileManager.default.fileExists(atPath: accountFile.path(percentEncoded: false)) { let startDate = Date() do { let fileContent = try Data(contentsOf: accountFile, options: [.alwaysMapped, .uncached]) - - // Decrypt data if encryption method is provided let unciphered = try ClassicAppAES.decrypt(fileContent, aesKey: aesKey, iv: iv) - let decoder = NSKeyedUnarchiver(forReadingWith: unciphered) + let decoder = try NSKeyedUnarchiver(forReadingFrom: unciphered) + decoder.requiresSecureCoding = false decoder.setClass(ClassicAppMXAccount.self, forClassName: "MXKAccount") guard let mxAccounts = decoder.decodeObject(forKey: "mxAccounts") as? [ClassicAppMXAccount] else { @@ -44,6 +44,8 @@ class ClassicAppAccountManager { MXLog.info("\(mxAccounts.count) accounts loaded in \(Date().timeIntervalSince(startDate) * 1000)ms.") + // Only consider active accounts using the same logic as Element Classic and then + // combine the MXAccount data with its profile and crypto store details. accounts = mxAccounts .filter(\.isActive) .compactMap(makeAccount) @@ -57,9 +59,10 @@ class ClassicAppAccountManager { // MARK: - Private + /// Combines an MXAccount with its profile and crypto store details. private func makeAccount(for mxAccount: ClassicAppMXAccount) -> ClassicAppAccount? { let userID = mxAccount.userID - let user = loadUser(for: mxAccount) + let user = loadUser(for: mxAccount) // We need an MXUser for the profile as MXAccount doesn't contain that data. guard let serverName = serverName(for: userID) else { return nil } @@ -72,13 +75,21 @@ class ClassicAppAccountManager { } private func loadUser(for mxAccount: ClassicAppMXAccount) -> ClassicAppMXUser? { + // Users are stored across multiple files, so first find the right file for this particular user. let userID = mxAccount.userID - let groupID = String(userID.hash % 100) - let groupFile = storeUsersPath(for: userID).appendingPathComponent(groupID) + let groupID = String(UInt(bitPattern: userID.hash) % 100) // Swift's .hash is Int whereas Objective-C's is UInt. + let groupFile = storeUsersPath(for: userID).appending(component: groupID) + + guard FileManager.default.fileExists(atPath: groupFile.path(percentEncoded: false)) else { + MXLog.warning("Missing users group \(groupID) file for \(mxAccount.userID).") + return nil + } do { + // And then load that file and find the user within its data. let fileContent = try Data(contentsOf: groupFile) - let decoder = NSKeyedUnarchiver(forReadingWith: fileContent) + let decoder = try NSKeyedUnarchiver(forReadingFrom: fileContent) + decoder.requiresSecureCoding = false decoder.setClass(ClassicAppMXUser.self, forClassName: "MXUser") decoder.setClass(ClassicAppMXUser.self, forClassName: "MXMyUser") @@ -92,7 +103,6 @@ class ClassicAppAccountManager { /// The server name extracted from the user's ID. private func serverName(for userID: String) -> String? { - #warning("Expose a serverName method for this from the SDK?") let components = userID.components(separatedBy: ":") guard components.count > 1 else { return nil } return components[1] // Directly use [1] as .last may be the port number. @@ -107,17 +117,17 @@ class ClassicAppAccountManager { private static let kMXFileStoreUsersFolder = "users" /// The file URL that contains the app's `MXAccounts` array. - private func accountFile() -> URL { + func accountFile() -> URL { cacheFolder.appending(component: Self.matrixKitFolder).appending(component: Self.kMXKAccountsKey) } /// The database file URL as defined in `MXCryptoMachineStore`. - private func cryptoStoreURL(for userID: String) -> URL { + func cryptoStoreURL(for userID: String) -> URL { cacheFolder.appending(component: Self.cryptoStoreFolder).appending(component: userID) } /// This store contains all of the users known to the specific user ID. - private func storeUsersPath(for userID: String) -> URL { + func storeUsersPath(for userID: String) -> URL { storePath(for: userID).appending(component: Self.kMXFileStoreUsersFolder) } diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift index bfb82b623..7c3af36c9 100644 --- a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppMXAccount.swift @@ -7,7 +7,7 @@ import Foundation -struct ClassicAppAccount { +struct ClassicAppAccount: Equatable { let userID: String let displayName: String? let avatarURL: URL? @@ -18,7 +18,7 @@ struct ClassicAppAccount { // MARK: NSCoding Types -class ClassicAppMXAccount: NSObject, NSCoding { +final class ClassicAppMXAccount: NSObject, NSCoding { /// The obtained user ID. var userID: String /// The homeserver url (ex: "https://matrix.org"). @@ -83,7 +83,7 @@ class ClassicAppMXAccount: NSObject, NSCoding { } /// `MXUser` represents a user in Matrix. -class ClassicAppMXUser: NSObject, NSCoding { +final class ClassicAppMXUser: NSObject, NSCoding { /// The user id. let userID: String /// The user display name. diff --git a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift index ce72c1ec8..c8d849727 100644 --- a/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift +++ b/ElementX/Sources/Services/Authentication/ClassicApp/ClassicAppManager.swift @@ -19,6 +19,7 @@ enum ClassicAppManagerError: Error { case missingCryptoStorePassphrase } +/// Reads accounts from Element Classic's shared storage. final class ClassicAppManager: ClassicAppManagerProtocol { private enum KeychainKeys: String { case cryptoSDKStoreKey @@ -29,16 +30,28 @@ final class ClassicAppManager: ClassicAppManagerProtocol { private let classicAppGroupIdentifier: String private let keychain: Keychain - init(classicAppGroupIdentifier: String, classicAppKeychainServiceIdentifier: String, classicAppKeychainAccessGroupIdentifier: String) { + /// Creates an instance using the Classic app identifiers specified in the `Info.plist` file. + /// Returns `nil` when a Classic app has not been configured in the project. + init?(classicAppGroupIdentifier: String? = InfoPlistReader.main.classicAppGroupIdentifier, + classicAppKeychainServiceIdentifier: String? = InfoPlistReader.main.classicAppKeychainServiceIdentifier, + classicAppKeychainAccessGroupIdentifier: String? = InfoPlistReader.main.classicAppKeychainAccessGroupIdentifier) { + guard let classicAppGroupIdentifier, let classicAppKeychainServiceIdentifier, let classicAppKeychainAccessGroupIdentifier else { + MXLog.info("Classic App IDs not available, skipping initialisation.") + return nil + } + self.classicAppGroupIdentifier = classicAppGroupIdentifier keychain = Keychain(service: classicAppKeychainServiceIdentifier, accessGroup: classicAppKeychainAccessGroupIdentifier) } + /// Loads all of the active accounts from the Classic app. func loadAccounts() throws -> [ClassicAppAccount] { + // The account data is stored in the App Group container. guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: classicAppGroupIdentifier) else { throw ClassicAppManagerError.invalidAppGroupIdentifier(classicAppGroupIdentifier) } + // And the data is encrypted with keys that are stored in the Keychain. guard let accountIV = try keychain.getData(KeychainKeys.accountIV.rawValue), let accountAESKey = try keychain.getData(KeychainKeys.accountAESKey.rawValue) else { throw ClassicAppManagerError.missingAccountKeys diff --git a/UnitTests/Sources/ClassicAppAccountManagerTests.swift b/UnitTests/Sources/ClassicAppAccountManagerTests.swift new file mode 100644 index 000000000..a75702b19 --- /dev/null +++ b/UnitTests/Sources/ClassicAppAccountManagerTests.swift @@ -0,0 +1,83 @@ +// +// 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 ClassicAppAccountManagerTests { + let testDirectory: URL = .temporaryDirectory.appending(component: UUID().uuidString) + let accountAESKey: Data + let accountIV: Data + let cryptoStorePassphrase: Data + + let classicAppAccountManager: ClassicAppAccountManager + + init() throws { + accountAESKey = try #require(Data(base64Encoded: "BzaSCm5i8QhJr6wGBPj7MDvqBwkwuHLqkRxprVV2zJE=")) + accountIV = try #require(Data(base64Encoded: "dMmg6H2dYTRBE8PwjfAbAQ==")) + cryptoStorePassphrase = try #require(Data(base64Encoded: "ERE/ZXw8rlY3Lv3MBG9sV9g+euOuJnrJaSuvrAMWPrI=")) + + classicAppAccountManager = ClassicAppAccountManager(cacheFolder: testDirectory, + aesKey: accountAESKey, + iv: accountIV, + cryptoStorePassphrase: cryptoStorePassphrase) + } + + @Test + func noAccounts() { + classicAppAccountManager.loadAccounts() + + #expect(classicAppAccountManager.accounts.isEmpty) + } + + @Test + func activeAccount() throws { + let account = ClassicAppAccount.mock(classicAppAccountManager: classicAppAccountManager, + cryptoStorePassphrase: cryptoStorePassphrase) + + try setupFixtures(for: account) + + classicAppAccountManager.loadAccounts() + + #expect(classicAppAccountManager.accounts.count == 1) + #expect(classicAppAccountManager.accounts.first == account) + } + + // MARK: - Helpers + + private func setupFixtures(for account: ClassicAppAccount) throws { + let bundle = Bundle(for: Self.self) + + // Copy the accountsV2 file (contains the MXKAccount for the signed in user). + let accountFileSource = try #require(bundle.url(forResource: "accountsV2", withExtension: nil)) + let accountFileDestination = classicAppAccountManager.accountFile() + try FileManager.default.createDirectory(at: accountFileDestination.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.copyItem(at: accountFileSource, to: accountFileDestination) + + // Copy the required users file (contains a subset of known MXUsers including the signed in user). + let userFileName = "94" // UInt(bitPattern: account.userID.hash) % 100 + let userFileSource = try #require(bundle.url(forResource: userFileName, withExtension: nil)) + let usersDestination = classicAppAccountManager.storeUsersPath(for: account.userID) + try FileManager.default.createDirectory(at: usersDestination, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: userFileSource, to: usersDestination.appending(component: userFileName)) + } +} + +extension ClassicAppAccount { + /// Creates a mock account based on the fixtures used by this test. + static func mock(classicAppAccountManager: ClassicAppAccountManager, cryptoStorePassphrase: Data) -> ClassicAppAccount { + let userID = "@classicappaccount:matrix.org" + + return ClassicAppAccount(userID: userID, + displayName: "Classic App Account", + avatarURL: "mxc://matrix.org/LYIzLOiILkjQJCqsgzAOUirs", + serverName: "matrix.org", + cryptoStoreURL: classicAppAccountManager.cryptoStoreURL(for: userID), + cryptoStorePassphrase: cryptoStorePassphrase) + } +} diff --git a/UnitTests/SupportingFiles/target.yml b/UnitTests/SupportingFiles/target.yml index b6f254a30..2c9deafb4 100644 --- a/UnitTests/SupportingFiles/target.yml +++ b/UnitTests/SupportingFiles/target.yml @@ -49,7 +49,9 @@ targets: sources: - path: ../Sources - path: ../SupportingFiles - - path: ../../DevelopmentAssets + - path: ../../DevelopmentAssets/Media + - path: ../../DevelopmentAssets/ClassicAppFixtures + buildPhase: resources # Necessary to add binary files to the target. - path: ../../ElementX/Sources/Other/Extensions/Publisher.swift - path: ../../ElementX/Sources/Other/Extensions/XCTestCase.swift - path: ../../ElementX/Sources/Other/InfoPlistReader.swift