From cfea204a3ea5935f7992acee4765cc77262e1f7a Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Thu, 15 Sep 2022 12:41:37 +0300 Subject: [PATCH] Log out (#174) * Expose logout method from SDK * Use logout from SDK, refactor logging out states * Add some strings * Introduce `initialDisplayName` on `UIDevice` * Implement soft logout screen * Add `softLogout` test screen identifier * Add new fields into the client proxy and implement new delegate methods * Add new fields into the user session and observe new client callbacks * Implement updated login method * Add remote logout state and event into the state machine * Implement refreshing restore token on `UserSessionStore` * Update app coordinator with new states and display soft logout screen when appropriate * Add reference screenshots for soft logout screen * Disable auto requesting photos access on screenshot detection * Make initial device name optional, generate project file * Add changelog * Use logout from SDK, refactor logging out states * Implement soft logout screen * Implement updated login method * Make initial device name optional, generate project file * Fix renamed event * Fix logout race * Remove redundant strings * Reuse existing strings * Confirm clear all data * Expose logout method from SDK * Use logout from SDK, refactor logging out states * Add some strings * Introduce `initialDisplayName` on `UIDevice` * Implement soft logout screen * Add `softLogout` test screen identifier * Add new fields into the client proxy and implement new delegate methods * Add new fields into the user session and observe new client callbacks * Implement updated login method * Add remote logout state and event into the state machine * Implement refreshing restore token on `UserSessionStore` * Update app coordinator with new states and display soft logout screen when appropriate * Add reference screenshots for soft logout screen * Disable auto requesting photos access on screenshot detection * Make initial device name optional, generate project file * Add changelog * Use logout from SDK, refactor logging out states * Implement soft logout screen * Implement updated login method * Make initial device name optional, generate project file * Fix renamed event * Fix logout race * Remove redundant strings * Reuse existing strings * Confirm clear all data * Comment out new apis for now * Make the PR compile --- ElementX.xcodeproj/project.pbxproj | 70 +++++-- .../en.lproj/Untranslated.strings | 3 + ElementX/Sources/AppCoordinator.swift | 176 +++++++++++++--- .../Sources/AppCoordinatorStateMachine.swift | 23 ++- .../Generated/Strings+Untranslated.swift | 4 + .../Sources/Other/Extensions/UIDevice.swift | 29 +++ .../LoginScreen/LoginCoordinator.swift | 5 +- .../MockSoftLogoutScreenState.swift | 68 +++++++ .../SoftLogout/SoftLogoutCoordinator.swift | 184 +++++++++++++++++ .../SoftLogout/SoftLogoutModels.swift | 107 ++++++++++ .../SoftLogout/SoftLogoutViewModel.swift | 69 +++++++ .../SoftLogoutViewModelProtocol.swift | 25 +++ .../SoftLogout/View/SoftLogoutScreen.swift | 190 ++++++++++++++++++ .../HomeScreen/HomeScreenCoordinator.swift | 2 + .../AuthenticationServiceProxy.swift | 10 +- .../AuthenticationServiceProxyProtocol.swift | 2 +- .../MockAuthenticationServiceProxy.swift | 4 +- .../BugReport/ScreenshotDetector.swift | 2 +- .../Sources/Services/Client/ClientProxy.swift | 66 +++++- .../Services/Client/ClientProxyProtocol.swift | 12 ++ .../Services/Client/MockClientProxy.swift | 8 + .../Services/Session/MockUserSession.swift | 3 + .../Services/Session/UserSession.swift | 43 +++- .../Session/UserSessionProtocol.swift | 5 + .../UserSessionStore/UserSessionStore.swift | 10 + .../UserSessionStoreProtocol.swift | 4 + ElementX/Sources/UITestScreenIdentifier.swift | 1 + ElementX/Sources/UITestsAppCoordinator.swift | 8 + UITests/Sources/SoftLogoutUITests.swift | 57 ++++++ ...5-de-DE-iPad-9th-generation.softLogout.png | 3 + ...5-5-de-DE-iPhone-13-Pro-Max.softLogout.png | 3 + ...5-en-GB-iPad-9th-generation.softLogout.png | 3 + ...5-5-en-GB-iPhone-13-Pro-Max.softLogout.png | 3 + ...5-fr-FR-iPad-9th-generation.softLogout.png | 3 + ...5-5-fr-FR-iPhone-13-Pro-Max.softLogout.png | 3 + .../Sources/SoftLogoutViewModelTests.swift | 91 +++++++++ changelog.d/104.feature | 1 + 37 files changed, 1237 insertions(+), 63 deletions(-) create mode 100644 ElementX/Sources/Other/Extensions/UIDevice.swift create mode 100644 ElementX/Sources/Screens/Authentication/SoftLogout/MockSoftLogoutScreenState.swift create mode 100644 ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutCoordinator.swift create mode 100644 ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutModels.swift create mode 100644 ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutViewModel.swift create mode 100644 ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutViewModelProtocol.swift create mode 100644 ElementX/Sources/Screens/Authentication/SoftLogout/View/SoftLogoutScreen.swift create mode 100644 UITests/Sources/SoftLogoutUITests.swift create mode 100644 UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.softLogout.png create mode 100644 UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.softLogout.png create mode 100644 UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.softLogout.png create mode 100644 UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.softLogout.png create mode 100644 UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.softLogout.png create mode 100644 UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.softLogout.png create mode 100644 UnitTests/Sources/SoftLogoutViewModelTests.swift create mode 100644 changelog.d/104.feature diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 9880d53d9..ee5e28d48 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 51; objects = { /* Begin PBXBuildFile section */ @@ -26,6 +26,7 @@ 071A017E415AD378F2961B11 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 227AC5D71A4CE43512062243 /* URL.swift */; }; 07240B7159A3990C4C2E8FFC /* LoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D256FEE2F1AF1E51D39B622 /* LoginTests.swift */; }; 072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */; }; + 09AAF04B27732046C755D914 /* SoftLogoutViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C5DAA1773F57653BF1C4F9 /* SoftLogoutViewModelTests.swift */; }; 0B1F80C2BF7D223159FBA82C /* ImageAnonymizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6045E825AE900A92D61FEFF0 /* ImageAnonymizerTests.swift */; }; 0C38C3E771B472E27295339D /* SessionVerificationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4BB9A17AC512A7EF4B106E5 /* SessionVerificationModels.swift */; }; 0E8C480700870BB34A2A360F /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 4346F63D53A346271577FD9C /* AppAuth */; }; @@ -44,6 +45,7 @@ 1555A7643D85187D4851040C /* TemplateScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4549FCB53F43DB0B278374BC /* TemplateScreen.swift */; }; 157E5FDDF419C0B2CA7E2C28 /* TimelineItemBubbledStylerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A2932515EA11D3DD8A3506 /* TimelineItemBubbledStylerView.swift */; }; 15D1F9C415D9C921643BA82E /* UserIndicatorRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B73D5E21F524A9BE44448D /* UserIndicatorRequest.swift */; }; + 165A883C29998EC779465068 /* SoftLogoutViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BC38904A9663F7FAFD47457 /* SoftLogoutViewModelProtocol.swift */; }; 1702981A8085BE4FB0EC001B /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33116993D54FADC0C721C1F /* Application.swift */; }; 172E6E9A612ADCF10A62CF13 /* BugReportServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A68BCE6438873D2661D93D0 /* BugReportServiceProtocol.swift */; }; 17CC4FB64F3A670F43ECBE5F /* UITestsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCA431E6EDD71F7067B5F9E7 /* UITestsRootView.swift */; }; @@ -57,7 +59,9 @@ 1E59B77A0B2CE83DCC1B203C /* LoginViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05707BF550D770168A406DB /* LoginViewModelTests.swift */; }; 1F3232BD368DF430AB433907 /* DesignKit in Frameworks */ = {isa = PBXBuildFile; productRef = A5A56C4F47C368EBE5C5E870 /* DesignKit */; }; 206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */; }; + 214C6B416609E58CCBF6DCEE /* SoftLogoutModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC77FC5C4F2000133047AA27 /* SoftLogoutModels.swift */; }; 224A55EEAEECF5336B14A4A5 /* EmoteRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2DF459F1737A594667CC46 /* EmoteRoomMessage.swift */; }; + 2276870A19F34B3FFFDA690F /* SoftLogoutCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AEA20A6B4883E60469ACF8F /* SoftLogoutCoordinator.swift */; }; 22DADD537401E79D66132134 /* NavigationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4173A48FD8542CD4AD3645C /* NavigationRouter.swift */; }; 237FC70AA257B935F53316BA /* SessionVerificationControllerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C55D7E514F9DE4E3D72FDCAD /* SessionVerificationControllerProxy.swift */; }; 23B2CD5A06B16055BDDD0994 /* ApplicationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44D8C8431416EB8DFEC7E235 /* ApplicationTests.swift */; }; @@ -180,6 +184,7 @@ 77E192BA943B90F9F310CA23 /* WeakDictionaryKeyReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FFCC48E7F701B6C24484593 /* WeakDictionaryKeyReference.swift */; }; 77FACC29F98FE2E65BBB6A5F /* ServerSelectionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */; }; 78B71D53C1FC55FB7A9B75F0 /* RoomTimelineController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B0C97D2F560BCB72BE73B1 /* RoomTimelineController.swift */; }; + 78BF60C696FFED63AAF58D10 /* SoftLogoutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22D46DB0CC6C55EBA7AE67A3 /* SoftLogoutViewModel.swift */; }; 7963F98CDFDEAC75E072BD81 /* TextRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A8C632CEF4600107792899 /* TextRoomTimelineItem.swift */; }; 79A6E08ADE6E7C460A8A17A5 /* UserSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C37FB986891D90BEAA93EAE /* UserSessionStore.swift */; }; 7A54700193DC1F264368746A /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E077F76026C85ED96FEBB810 /* UserIndicatorPresenter.swift */; }; @@ -242,6 +247,7 @@ A0A0D2A9564BDA3FDE2E360F /* FormattedBodyText.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73FF1A33198F5FAE9D34B1F /* FormattedBodyText.swift */; }; A32517FB1CA0BBCE2BC75249 /* BugReportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6C07DA7D3FF193F7419F55 /* BugReportCoordinator.swift */; }; A371629728E597C5FCA3C2B2 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FC861755C6388F62B9280A /* Analytics.swift */; }; + A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 287FC98AF2664EAD79C0D902 /* UIDevice.swift */; }; A4E885358D7DD5A072A06824 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = CCE5BF78B125320CBF3BB834 /* PostHog */; }; A50849766F056FD1DB942DEA /* AlertInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EEB64CC6F3DF5B68736A6B4 /* AlertInfo.swift */; }; A5C8F013ED9FB8AA6FEE18A7 /* InfoPlist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A901D95158B02CA96C79C7F /* InfoPlist.swift */; }; @@ -258,6 +264,8 @@ ABF3FAB234AD3565B214309B /* TimelineSenderAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */; }; ACF094CF3BF02DBFA6DFDE60 /* AuthenticationCoordinatorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2D0A6F1ABC99D29462FB84 /* AuthenticationCoordinatorUITests.swift */; }; B037C365CF8A58A0D149A2DB /* AuthenticationIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97755C01C3971474EFAD5367 /* AuthenticationIconImage.swift */; }; + B064D42BA087649ACAE462E8 /* SoftLogoutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55F30E764BED111C81739844 /* SoftLogoutUITests.swift */; }; + B09514A0A3EB3C19A4FD0B71 /* SoftLogoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CCBDE671A613B3EB70794C4 /* SoftLogoutScreen.swift */; }; B245583C63F8F90357B87FAE /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 04C28663564E008DB32B5972 /* Introspect */; }; B3357B00F1AA930E54F76609 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47EBB5D698CE9A25BB553A2D /* Strings.swift */; }; B3FDB1D9CF40777695DBBD1D /* AppCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A9AB74614131D6706894E0C /* AppCoordinatorStateMachine.swift */; }; @@ -281,6 +289,7 @@ C4180F418235DAD9DD173951 /* TemplateScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9873076F224E4CE09D8BD47D /* TemplateScreenUITests.swift */; }; C4F69156C31A447FEFF2A47C /* DTHTMLElement+AttributedStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E508AB0EDEE017FF4F6F8D1 /* DTHTMLElement+AttributedStringBuilder.swift */; }; C55A44C99F64A479ABA85B46 /* RoomScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */; }; + C74EE50257ED925C2B8EFCE6 /* MockSoftLogoutScreenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B869438A1B52836F912A702 /* MockSoftLogoutScreenState.swift */; }; C76892321558E75101E68ED6 /* ReadableFrameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */; }; C7B251DC896C0867C51B616D /* AnalyticsPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541542F5AC323709D8563458 /* AnalyticsPrompt.swift */; }; C7CFDB4929DDD9A3B5BA085D /* BugReportViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB7ED3A898B07976F3AA90F /* BugReportViewModelTests.swift */; }; @@ -388,6 +397,7 @@ 09747989908EC5E4AA29F844 /* MemberDetailsProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberDetailsProviderProtocol.swift; sourceTree = ""; }; 0A191D3FDB995309C7E2DE7D /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 0AB7A0C06CB527A1095DEB33 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = da; path = da.lproj/Localizable.stringsdict; sourceTree = ""; }; + 0B869438A1B52836F912A702 /* MockSoftLogoutScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSoftLogoutScreenState.swift; sourceTree = ""; }; 0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSenderAvatarView.swift; sourceTree = ""; }; 0C13A92C1E9C79F055B8133D /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; }; 0CB569EAA5017B5B23970655 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; @@ -427,26 +437,31 @@ 21BA866267F84BF4350B0CB7 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "pt-BR"; path = "pt-BR.lproj/Localizable.stringsdict"; sourceTree = ""; }; 227AC5D71A4CE43512062243 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 22B384D54464FA39C6C7F6E7 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ca; path = ca.lproj/Localizable.stringsdict; sourceTree = ""; }; + 22D46DB0CC6C55EBA7AE67A3 /* SoftLogoutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutViewModel.swift; sourceTree = ""; }; 233D5F7E5E9F49ABF3413291 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = hr; path = hr.lproj/Localizable.stringsdict; sourceTree = ""; }; 24A534A4619D8FEFB6439FCC /* SplashScreenPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenPageView.swift; sourceTree = ""; }; 24B0C97D2F560BCB72BE73B1 /* RoomTimelineController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineController.swift; sourceTree = ""; }; 24F5530B2212862FA4BEFF2D /* HomeScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelProtocol.swift; sourceTree = ""; }; 2583416C8974272ADBADDBE1 /* zh-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-TW"; path = "zh-TW.lproj/Localizable.stringsdict"; sourceTree = ""; }; 26C4D226FCD20BAC53F1E092 /* ml */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ml; path = ml.lproj/Localizable.strings; sourceTree = ""; }; + 287FC98AF2664EAD79C0D902 /* UIDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIDevice.swift; sourceTree = ""; }; 28959C7DB36C7688A01D4045 /* BugReportViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportViewModelProtocol.swift; sourceTree = ""; }; 289FA233E896FBC5956C67E0 /* RoomTimelineItemProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProperties.swift; sourceTree = ""; }; 28EA8BE9EEDBD17555141C7E /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = el; path = el.lproj/Localizable.stringsdict; sourceTree = ""; }; 29A953B6C0C431DBF4DD00B4 /* RoomSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummary.swift; sourceTree = ""; }; 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = ""; }; 2AE83A3DD63BCFBB956FE5CB /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = nl; path = nl.lproj/Localizable.stringsdict; sourceTree = ""; }; + 2AEA20A6B4883E60469ACF8F /* SoftLogoutCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutCoordinator.swift; sourceTree = ""; }; 2B80895CE021B49847BD7D74 /* TemplateViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModelProtocol.swift; sourceTree = ""; }; 2CA028DCD4157F9A1F999827 /* BackgroundTaskProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskProtocol.swift; sourceTree = ""; }; + 2CCBDE671A613B3EB70794C4 /* SoftLogoutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreen.swift; sourceTree = ""; }; 2CF9FE7E0CF9F40D1509E63A /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = bg; path = bg.lproj/Localizable.stringsdict; sourceTree = ""; }; 2D256FEE2F1AF1E51D39B622 /* LoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginTests.swift; sourceTree = ""; }; 2EEB64CC6F3DF5B68736A6B4 /* AlertInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertInfo.swift; sourceTree = ""; }; 31B01468022EC826CB2FD2C0 /* LoginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginModels.swift; sourceTree = ""; }; 31D6764D6976D235926FE5FC /* HomeScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModel.swift; sourceTree = ""; }; 325A2B3278875554DDEB8A9B /* SplashScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenUITests.swift; sourceTree = ""; }; + 32C5DAA1773F57653BF1C4F9 /* SoftLogoutViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutViewModelTests.swift; sourceTree = ""; }; 32CE6D4FF64C9A3C18619224 /* SplashScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreen.swift; sourceTree = ""; }; 3340ABAE3A4647E80163AE18 /* TemplateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateViewModelTests.swift; sourceTree = ""; }; 33E49C5C6F802B4D94CA78D1 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; @@ -520,6 +535,7 @@ 54E438DBCBDC7A41B95DDDD9 /* MXLogObjcWrapper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MXLogObjcWrapper.m; sourceTree = ""; }; 55BC11560C8A2598964FFA4C /* bs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bs; path = bs.lproj/Localizable.strings; sourceTree = ""; }; 55D7187F6B0C0A651AC3DFFA /* in */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = in; path = in.lproj/Localizable.strings; sourceTree = ""; }; + 55F30E764BED111C81739844 /* SoftLogoutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutUITests.swift; sourceTree = ""; }; 56F01DD1BBD4450E18115916 /* LabelledActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelledActivityIndicatorView.swift; sourceTree = ""; }; 5773C86AF04AEF26515AD00C /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; 5872785B9C7934940146BFBA /* MXLogger.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MXLogger.h; sourceTree = ""; }; @@ -533,6 +549,7 @@ 5F12E996BFBEB43815189ABF /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; 5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionProtocol.swift; sourceTree = ""; }; 5F77E8010D41AA3F5F9A1FCA /* NavigationModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModule.swift; sourceTree = ""; }; + 5F9C6A30C7FB368AD539E7F7 /* matrix-rust-components-swift */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "matrix-rust-components-swift"; path = "../matrix-rust-components-swift"; sourceTree = SOURCE_ROOT; }; 5FD9D66B75292F2CC11AA4D2 /* UITestScreenIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestScreenIdentifier.swift; sourceTree = ""; }; 5FF214969B25BFCBF87B908B /* bn-BD */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "bn-BD"; path = "bn-BD.lproj/Localizable.stringsdict"; sourceTree = ""; }; 6033779EB37259F27F938937 /* ClientProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxyProtocol.swift; sourceTree = ""; }; @@ -558,6 +575,7 @@ 6A901D95158B02CA96C79C7F /* InfoPlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoPlist.swift; sourceTree = ""; }; 6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixEntityRegex.swift; sourceTree = ""; }; 6B73A8C3118EAC7BF3F3EE7A /* SplashScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenViewModelProtocol.swift; sourceTree = ""; }; + 6BC38904A9663F7FAFD47457 /* SoftLogoutViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutViewModelProtocol.swift; sourceTree = ""; }; 6D4777F0142E330A75C46FE4 /* SessionVerificationUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationUITests.swift; sourceTree = ""; }; 6DB53055CB130F0651C70763 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; 6DFCAA239095A116976E32C4 /* BackgroundTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskTests.swift; sourceTree = ""; }; @@ -611,7 +629,7 @@ 8C37FB986891D90BEAA93EAE /* UserSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionStore.swift; sourceTree = ""; }; 8D8169443E5AC5FF71BFB3DB /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; 8DC2C9E0E15C79BBDA80F0A2 /* TimelineStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStyle.swift; sourceTree = ""; }; - 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITests.xctestplan; sourceTree = ""; }; + 8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = ""; }; 9010EE0CC913D095887EF36E /* OIDCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCService.swift; sourceTree = ""; }; 90733775209F4D4D366A268F /* RootRouterType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootRouterType.swift; sourceTree = ""; }; 92B61C243325DC76D3086494 /* EventBriefFactoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBriefFactoryProtocol.swift; sourceTree = ""; }; @@ -749,6 +767,7 @@ D77B3D4950F1707E66E4A45A /* AnalyticsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsConfiguration.swift; sourceTree = ""; }; DBD460ED7ED1E03B85DEA25C /* TemplateCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateCoordinator.swift; sourceTree = ""; }; DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXTests.swift; sourceTree = ""; }; + DC77FC5C4F2000133047AA27 /* SoftLogoutModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutModels.swift; sourceTree = ""; }; DCE978A6118C131D7F2A04B3 /* SplashScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenModels.swift; sourceTree = ""; }; DD667C4BB98CF4F3FE2CE3B0 /* LoginCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginCoordinator.swift; sourceTree = ""; }; DD73FAAA4A76CE4A1F3014D9 /* UserIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicator.swift; sourceTree = ""; }; @@ -1078,6 +1097,7 @@ B6E89E530A8E92EC44301CA1 /* Bundle.swift */, E26747B3154A5DBC3A7E24A5 /* Image.swift */, 40B21E611DADDEF00307E7AC /* String.swift */, + 287FC98AF2664EAD79C0D902 /* UIDevice.swift */, 227AC5D71A4CE43512062243 /* URL.swift */, ); path = Extensions; @@ -1302,6 +1322,7 @@ A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */, DF05DA24F71B455E8EFEBC3B /* SessionVerificationViewModelTests.swift */, 3D487C1185D658F8B15B8F55 /* SettingsViewModelTests.swift */, + 32C5DAA1773F57653BF1C4F9 /* SoftLogoutViewModelTests.swift */, 7B5CF94E124616FD89424B73 /* SplashScreenViewModelTests.swift */, AF552BB969DC98A4BB8CF8D5 /* UserIndicators */, ); @@ -1454,6 +1475,7 @@ isa = PBXGroup; children = ( D31DC8105C6233E5FFD9B84C /* element-x-ios */, + 5F9C6A30C7FB368AD539E7F7 /* matrix-rust-components-swift */, ); name = Packages; sourceTree = SOURCE_ROOT; @@ -1471,6 +1493,7 @@ 054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */, 6D4777F0142E330A75C46FE4 /* SessionVerificationUITests.swift */, E3E29F98CF0E960689A410E3 /* SettingsUITests.swift */, + 55F30E764BED111C81739844 /* SoftLogoutUITests.swift */, 325A2B3278875554DDEB8A9B /* SplashScreenUITests.swift */, ); path = Sources; @@ -1639,6 +1662,19 @@ path = Timeline; sourceTree = ""; }; + B9F8C25B353B751013FAACC7 /* SoftLogout */ = { + isa = PBXGroup; + children = ( + 0B869438A1B52836F912A702 /* MockSoftLogoutScreenState.swift */, + 2AEA20A6B4883E60469ACF8F /* SoftLogoutCoordinator.swift */, + DC77FC5C4F2000133047AA27 /* SoftLogoutModels.swift */, + 22D46DB0CC6C55EBA7AE67A3 /* SoftLogoutViewModel.swift */, + 6BC38904A9663F7FAFD47457 /* SoftLogoutViewModelProtocol.swift */, + CE6E6768A3FF47C9EABD3007 /* View */, + ); + path = SoftLogout; + sourceTree = ""; + }; C0937E3B06A8F0E2DB7C8241 /* Other */ = { isa = PBXGroup; children = ( @@ -1690,6 +1726,14 @@ path = Layout; sourceTree = ""; }; + CE6E6768A3FF47C9EABD3007 /* View */ = { + isa = PBXGroup; + children = ( + 2CCBDE671A613B3EB70794C4 /* SoftLogoutScreen.swift */, + ); + path = View; + sourceTree = ""; + }; D958761758AA1110476DE6A3 /* SessionVerification */ = { isa = PBXGroup; children = ( @@ -1773,6 +1817,7 @@ 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */, 90F48FEF84016ED42A94BA24 /* LoginScreen */, 3510020809E49EFA146296AD /* ServerSelection */, + B9F8C25B353B751013FAACC7 /* SoftLogout */, ); path = Authentication; sourceTree = ""; @@ -2069,7 +2114,6 @@ 9A472EE0218FE7DCF5283429 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, 61916C63E3F5BD900F08DA0C /* XCRemoteSwiftPackageReference "KeychainAccess" */, D283517192CAC3E2E6920765 /* XCRemoteSwiftPackageReference "Kingfisher" */, - 80B898A3AD2AC63F3ABFC218 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */, 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */, A08925A9D5E3770DEB9D8509 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, E9C4F3A12AA1F65C13A8C8EB /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, @@ -2239,6 +2283,7 @@ 86675910612A12409262DFBD /* SessionVerificationStateMachineTests.swift in Sources */, 755727E0B756430DFFEC4732 /* SessionVerificationViewModelTests.swift in Sources */, 206F0DBAB6AF042CA1FF2C0D /* SettingsViewModelTests.swift in Sources */, + 09AAF04B27732046C755D914 /* SoftLogoutViewModelTests.swift in Sources */, 94E062D08E27B0387658E364 /* SplashScreenViewModelTests.swift in Sources */, 7AE1FFB132F2B84EB8A2AEBC /* TemplateViewModelTests.swift in Sources */, 1151DCC5EC2C6585826545EC /* UserIndicatorPresenterSpy.swift in Sources */, @@ -2360,6 +2405,7 @@ E81EEC1675F2371D12A880A3 /* MockRoomTimelineController.swift in Sources */, 9BE7A9CF6C593251D734B461 /* MockServerSelectionScreenState.swift in Sources */, D034A195A3494E38BF060485 /* MockSessionVerificationControllerProxy.swift in Sources */, + C74EE50257ED925C2B8EFCE6 /* MockSoftLogoutScreenState.swift in Sources */, D8359F67AF3A83516E9083C1 /* MockUserSession.swift in Sources */, 4ED453A61AF45EBE18D8BC69 /* NavigationModule.swift in Sources */, 22DADD537401E79D66132134 /* NavigationRouter.swift in Sources */, @@ -2425,6 +2471,11 @@ 7FED310F6AB7A70CBFB7C8A3 /* SettingsScreen.swift in Sources */, 4A2E0DBB63919AC8309B6D40 /* SettingsViewModel.swift in Sources */, 438FB9BC535BC95948AA5F34 /* SettingsViewModelProtocol.swift in Sources */, + 2276870A19F34B3FFFDA690F /* SoftLogoutCoordinator.swift in Sources */, + 214C6B416609E58CCBF6DCEE /* SoftLogoutModels.swift in Sources */, + B09514A0A3EB3C19A4FD0B71 /* SoftLogoutScreen.swift in Sources */, + 78BF60C696FFED63AAF58D10 /* SoftLogoutViewModel.swift in Sources */, + 165A883C29998EC779465068 /* SoftLogoutViewModelProtocol.swift in Sources */, 684BDE198AE5AA1392288A73 /* SplashScreen.swift in Sources */, CE7A715947ABAB1DEB5C21D7 /* SplashScreenCoordinator.swift in Sources */, EF99A92701E401C4CD5ADC50 /* SplashScreenModels.swift in Sources */, @@ -2458,6 +2509,7 @@ 4669804D0369FBED4E8625D1 /* ToastViewPresenter.swift in Sources */, 9CB5129C83F75921E5E28028 /* ToastViewState.swift in Sources */, 36AC963F2F04069B7FF1AA0C /* UIConstants.swift in Sources */, + A37EED79941AD3B7140B3822 /* UIDevice.swift in Sources */, 0EE5EBA18BA1FE10254BB489 /* UIFont+AttributedStringBuilder.m in Sources */, 706F79A39BDB32F592B8C2C7 /* UIKitBackgroundTask.swift in Sources */, 3097A0A867D2B19CE32DAE58 /* UIKitBackgroundTaskService.swift in Sources */, @@ -2504,6 +2556,7 @@ 77FACC29F98FE2E65BBB6A5F /* ServerSelectionUITests.swift in Sources */, 05EC896A4B9AF4A56670C0BB /* SessionVerificationUITests.swift in Sources */, 490E606044B18985055FF690 /* SettingsUITests.swift in Sources */, + B064D42BA087649ACAE462E8 /* SoftLogoutUITests.swift in Sources */, A00DFC1DD3567B1EDC9F8D16 /* SplashScreenUITests.swift in Sources */, DD9B70DE54B24E0694A35D8A /* Strings+Untranslated.swift in Sources */, B3357B00F1AA930E54F76609 /* Strings.swift in Sources */, @@ -3090,14 +3143,6 @@ minimumVersion = 1.3.0; }; }; - 80B898A3AD2AC63F3ABFC218 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/matrix-org/matrix-rust-components-swift"; - requirement = { - kind = exactVersion; - version = "1.0.13-alpha"; - }; - }; 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/PostHog/posthog-ios"; @@ -3278,7 +3323,6 @@ }; A678E40E917620059695F067 /* MatrixRustSDK */ = { isa = XCSwiftPackageProductDependency; - package = 80B898A3AD2AC63F3ABFC218 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */; productName = MatrixRustSDK; }; A981A4CA233FB5C13B9CA690 /* SwiftyBeaver */ = { @@ -3298,12 +3342,10 @@ }; B1E8B697DF78FE7F61FC6CA4 /* MatrixRustSDK */ = { isa = XCSwiftPackageProductDependency; - package = 80B898A3AD2AC63F3ABFC218 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */; productName = MatrixRustSDK; }; C2C69B8BA5A9702E7A8BC08F /* MatrixRustSDK */ = { isa = XCSwiftPackageProductDependency; - package = 80B898A3AD2AC63F3ABFC218 /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */; productName = MatrixRustSDK; }; CCE5BF78B125320CBF3BB834 /* PostHog */ = { diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 314e0e8a5..56fa49da8 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -28,3 +28,6 @@ "server_selection_server_url" = "Server URL"; "server_selection_server_footer" = "You can only connect to a server that has already been set up"; "server_selection_generic_error" = "Cannot find a server at this URL, please check it is correct."; + +"login_mobile_device" = "Mobile"; +"login_tablet_device" = "Tablet"; diff --git a/ElementX/Sources/AppCoordinator.swift b/ElementX/Sources/AppCoordinator.swift index 5a1476f00..967b78020 100644 --- a/ElementX/Sources/AppCoordinator.swift +++ b/ElementX/Sources/AppCoordinator.swift @@ -31,7 +31,7 @@ struct ServiceLocator { let userIndicatorPresenter: UserIndicatorTypePresenter } -class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { +class AppCoordinator: Coordinator { private let window: UIWindow private let stateMachine: AppCoordinatorStateMachine @@ -43,7 +43,14 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { private let userSessionStore: UserSessionStoreProtocol - private var userSession: UserSessionProtocol! + private var userSession: UserSessionProtocol! { + didSet { + deobserveUserSessionChanges() + if let userSession = userSession, !userSession.isSoftLogout { + observeUserSessionChanges() + } + } + } private let memberDetailProviderManager: MemberDetailProviderManager @@ -53,6 +60,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { private var loadingIndicator: UserIndicator? private var statusIndicator: UserIndicator? + + private var cancellables = Set() var childCoordinators: [Coordinator] = [] @@ -96,15 +105,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { window.makeKeyAndVisible() stateMachine.processEvent(userSessionStore.hasSessions ? .startWithExistingSession : .startWithAuthentication) } - - // MARK: - AuthenticationCoordinatorDelegate - - func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) { - self.userSession = userSession - remove(childCoordinator: authenticationCoordinator) - stateMachine.processEvent(.succeededSigningIn) - } - + // MARK: - Private private func setupLogging() { @@ -147,6 +148,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { case (.restoringSession, .failedRestoringSession, .signedOut): self.hideLoadingIndicator() self.showLoginErrorToast() + self.startAuthentication() case (.restoringSession, .succeededRestoringSession, .homeScreen): self.hideLoadingIndicator() self.presentHomeScreen() @@ -155,13 +157,20 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { self.presentRoomWithIdentifier(roomId) case(.roomScreen, .dismissedRoomScreen, .homeScreen): self.tearDownDismissedRoomScreen() - case (_, .attemptSignOut, .signingOut): - self.userSessionStore.logout(userSession: self.userSession) - self.stateMachine.processEvent(.succeededSigningOut) - case (.signingOut, .succeededSigningOut, .signedOut): + + case (_, .signOut, .signingOut): + self.showLoadingIndicator() self.tearDownUserSession() - case (.signingOut, .failedSigningOut, _): - self.showLogoutErrorToast() + case (.signingOut, .completedSigningOut, .signedOut): + self.presentSplashScreen() + self.hideLoadingIndicator() + + case (_, .remoteSignOut(let isSoft), .remoteSigningOut): + self.showLoadingIndicator() + self.tearDownUserSession(isSoftLogout: isSoft) + case (.remoteSigningOut(let isSoft), .completedSigningOut, .signedOut): + self.presentSplashScreen(isSoftLogout: isSoft) + self.hideLoadingIndicator() case (.homeScreen, .showSettingsScreen, .settingsScreen): self.presentSettingsScreen() @@ -190,7 +199,11 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { switch await userSessionStore.restoreUserSession() { case .success(let userSession): self.userSession = userSession - stateMachine.processEvent(.succeededRestoringSession) + if userSession.isSoftLogout { + stateMachine.processEvent(.remoteSignOut(isSoft: true)) + } else { + stateMachine.processEvent(.succeededRestoringSession) + } case .failure: MXLog.error("Failed to restore an existing session.") stateMachine.processEvent(.failedRestoringSession) @@ -207,17 +220,80 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { add(childCoordinator: coordinator) coordinator.start() } + + private func startAuthenticationSoftLogout() { + Task { + var displayName = "" + if case .success(let name) = await userSession.clientProxy.loadUserDisplayName() { + displayName = name + } + + let credentials = SoftLogoutCredentials(userId: userSession.userID, + homeserverName: userSession.homeserver, + userDisplayName: displayName, + deviceId: userSession.deviceId) + + let authenticationService = AuthenticationServiceProxy(userSessionStore: userSessionStore) + _ = await authenticationService.configure(for: userSession.homeserver) + + let parameters = SoftLogoutCoordinatorParameters(authenticationService: authenticationService, + credentials: credentials, + keyBackupNeeded: false) + let coordinator = SoftLogoutCoordinator(parameters: parameters) + coordinator.callback = { result in + switch result { + case .signedIn(let session): + self.userSession = session + self.remove(childCoordinator: coordinator) + self.stateMachine.processEvent(.succeededSigningIn) + case .clearAllData: + self.confirmClearAllData { + // clear user data + self.userSessionStore.logout(userSession: self.userSession) + self.userSession = nil + self.remove(childCoordinator: coordinator) + self.startAuthentication() + } + } + } + + add(childCoordinator: coordinator) + coordinator.start() + + navigationRouter.setRootModule(coordinator) + } + } - private func tearDownUserSession() { + private func tearDownUserSession(isSoftLogout: Bool = false) { + Task { + deobserveUserSessionChanges() + + if !isSoftLogout { + // first log out from the server + _ = await userSession.clientProxy.logout() + + // regardless of the result, clear user data + userSessionStore.logout(userSession: userSession) + userSession = nil + } + + // complete logging out + stateMachine.processEvent(.completedSigningOut) + } + } + + private func presentSplashScreen(isSoftLogout: Bool = false) { if let presentedCoordinator = childCoordinators.first { remove(childCoordinator: presentedCoordinator) } - - userSession = nil - + mainNavigationController.setViewControllers([splashViewController], animated: false) - - startAuthentication() + + if isSoftLogout { + startAuthenticationSoftLogout() + } else { + startAuthentication() + } } private func presentHomeScreen() { @@ -250,6 +326,29 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { showCrashPopup() } } + + private func observeUserSessionChanges() { + userSession.callbacks + .receive(on: DispatchQueue.main) + .sink { [weak self] callback in + guard let self = self else { return } + switch callback { + case .didReceiveAuthError(let isSoftLogout): + self.stateMachine.processEvent(.remoteSignOut(isSoft: isSoftLogout)) + case .updateRestoreTokenNeeded: + if let userSession = self.userSession { + _ = self.userSessionStore.refreshRestoreToken(for: userSession) + } + default: + break + } + }.store(in: &cancellables) + } + + private func deobserveUserSessionChanges() { + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + } // MARK: Rooms @@ -305,7 +404,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { guard let self = self else { return } switch action { case .logout: - self.stateMachine.processEvent(.attemptSignOut) + self.stateMachine.processEvent(.signOut) } } @@ -346,7 +445,20 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { alert.addAction(UIAlertAction(title: ElementL10n.actionCancel, style: .cancel)) alert.addAction(UIAlertAction(title: ElementL10n.actionSignOut, style: .destructive) { [weak self] _ in - self?.stateMachine.processEvent(.attemptSignOut) + self?.stateMachine.processEvent(.signOut) + }) + + navigationRouter.present(alert, animated: true) + } + + /// Shows a confirmation to clear all data, and proceeds to do so if the user confirms. + private func confirmClearAllData(_ confirmed: @escaping () -> Void) { + let alert = UIAlertController(title: ElementL10n.softLogoutClearDataDialogTitle, + message: ElementL10n.softLogoutClearDataDialogContent, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: ElementL10n.actionCancel, style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: ElementL10n.actionSignOut, style: .destructive) { _ in + confirmed() }) navigationRouter.present(alert, animated: true) @@ -444,8 +556,14 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { private func showLoginErrorToast() { statusIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.error(label: "Failed logging in")) } - - private func showLogoutErrorToast() { - statusIndicator = ServiceLocator.shared.userIndicatorPresenter.present(.error(label: "Failed logging out")) +} + +// MARK: - AuthenticationCoordinatorDelegate + +extension AppCoordinator: AuthenticationCoordinatorDelegate { + func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) { + self.userSession = userSession + remove(childCoordinator: authenticationCoordinator) + stateMachine.processEvent(.succeededSigningIn) } } diff --git a/ElementX/Sources/AppCoordinatorStateMachine.swift b/ElementX/Sources/AppCoordinatorStateMachine.swift index ef7e874b2..fbd6c02f2 100644 --- a/ElementX/Sources/AppCoordinatorStateMachine.swift +++ b/ElementX/Sources/AppCoordinatorStateMachine.swift @@ -41,6 +41,9 @@ class AppCoordinatorStateMachine { /// Processing a sign out request case signingOut + + /// Processing a remote sign out + case remoteSigningOut(isSoft: Bool) } /// Events that can be triggered on the AppCoordinator state machine @@ -58,11 +61,11 @@ class AppCoordinatorStateMachine { case failedRestoringSession /// Request sign out - case attemptSignOut - /// Signing out succeeded - case succeededSigningOut - /// Signing out failed - case failedSigningOut + case signOut + /// Remote sign out. + case remoteSignOut(isSoft: Bool) + /// Signing out completed + case completedSigningOut /// Request presentation for a particular room /// - Parameter roomId:the room identifier @@ -92,10 +95,8 @@ class AppCoordinatorStateMachine { machine.addRoutes(event: .succeededRestoringSession, transitions: [.restoringSession => .homeScreen]) machine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut]) - machine.addRoutes(event: .attemptSignOut, transitions: [.any => .signingOut]) - - machine.addRoutes(event: .succeededSigningOut, transitions: [.signingOut => .signedOut]) - machine.addRoutes(event: .failedSigningOut, transitions: [.signingOut => .settingsScreen]) + machine.addRoutes(event: .signOut, transitions: [.any => .signingOut]) + machine.addRoutes(event: .completedSigningOut, transitions: [.signingOut => .signedOut]) machine.addRoutes(event: .showSettingsScreen, transitions: [.homeScreen => .settingsScreen]) machine.addRoutes(event: .dismissedSettingsScreen, transitions: [.settingsScreen => .homeScreen]) @@ -110,6 +111,10 @@ class AppCoordinatorStateMachine { return .roomScreen(roomId: roomId) case (.dismissedRoomScreen, .roomScreen): return .homeScreen + case (.remoteSignOut(let isSoft), _): + return .remoteSigningOut(isSoft: isSoft) + case (.completedSigningOut, .remoteSigningOut): + return .signedOut default: return nil } diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index fd75f2e02..acf8ffd05 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -22,6 +22,10 @@ extension ElementL10n { public static let authenticationServerInfoMatrixDescription = ElementL10n.tr("Untranslated", "authentication_server_info_matrix_description") /// Choose your server to store your data public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title") + /// Mobile + public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device") + /// Tablet + public static let loginTabletDevice = ElementL10n.tr("Untranslated", "login_tablet_device") /// Failed creating the permalink public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure") /// Replying to %@ diff --git a/ElementX/Sources/Other/Extensions/UIDevice.swift b/ElementX/Sources/Other/Extensions/UIDevice.swift new file mode 100644 index 000000000..c3c00713e --- /dev/null +++ b/ElementX/Sources/Other/Extensions/UIDevice.swift @@ -0,0 +1,29 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +extension UIDevice { + /// Returns if the device is a Phone + var isPhone: Bool { + userInterfaceIdiom == .phone + } + + var initialDisplayName: String { + let string = isPhone ? ElementL10n.loginMobileDevice : ElementL10n.loginTabletDevice + return "X \(string)" + } +} diff --git a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift index 1d1e9e8d1..9fce0253e 100644 --- a/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/LoginScreen/LoginCoordinator.swift @@ -165,7 +165,10 @@ final class LoginCoordinator: Coordinator, Presentable { startLoading(isInteractionBlocking: true) Task { - switch await authenticationService.login(username: username, password: password) { + switch await authenticationService.login(username: username, + password: password, + initialDeviceName: UIDevice.current.initialDisplayName, + deviceId: nil) { case .success(let userSession): callback?(.signedIn(userSession)) stopLoading() diff --git a/ElementX/Sources/Screens/Authentication/SoftLogout/MockSoftLogoutScreenState.swift b/ElementX/Sources/Screens/Authentication/SoftLogout/MockSoftLogoutScreenState.swift new file mode 100644 index 000000000..f4d5a7d88 --- /dev/null +++ b/ElementX/Sources/Screens/Authentication/SoftLogout/MockSoftLogoutScreenState.swift @@ -0,0 +1,68 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +enum MockSoftLogoutScreenState: String, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case emptyPassword + case enteredPassword + case oidc + case unsupported + case keyBackupNeeded + + /// Generate the view struct for the screen state. + @MainActor var viewModel: SoftLogoutViewModel { + let credentials = SoftLogoutCredentials(userId: "@mock:matrix.org", + homeserverName: "matrix.org", + userDisplayName: "mock", + deviceId: nil) + switch self { + case .emptyPassword: + return SoftLogoutViewModel(credentials: credentials, + homeserver: .mockMatrixDotOrg, + keyBackupNeeded: false) + case .enteredPassword: + return SoftLogoutViewModel(credentials: credentials, + homeserver: .mockMatrixDotOrg, + keyBackupNeeded: false, + password: "12345678") + case .oidc: + return SoftLogoutViewModel(credentials: credentials, + homeserver: .mockOIDC, + keyBackupNeeded: false) + case .unsupported: + return SoftLogoutViewModel(credentials: credentials, + homeserver: .mockUnsupported, + keyBackupNeeded: false) + case .keyBackupNeeded: + return SoftLogoutViewModel(credentials: credentials, + homeserver: .mockMatrixDotOrg, + keyBackupNeeded: true) + } + } +} + +extension MockSoftLogoutScreenState: Identifiable { + var id: String { + rawValue + } +} diff --git a/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutCoordinator.swift b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutCoordinator.swift new file mode 100644 index 000000000..cfbfb889f --- /dev/null +++ b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutCoordinator.swift @@ -0,0 +1,184 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppAuth +import MatrixRustSDK +import SwiftUI + +struct SoftLogoutCoordinatorParameters { + let authenticationService: AuthenticationServiceProxyProtocol + let credentials: SoftLogoutCredentials + let keyBackupNeeded: Bool +} + +enum SoftLogoutCoordinatorResult: CustomStringConvertible { + /// Login was successful. + case signedIn(UserSessionProtocol) + /// Clear all user data + case clearAllData + + /// A string representation of the result, ignoring any associated values that could leak PII. + var description: String { + switch self { + case .signedIn: + return "signedIn" + case .clearAllData: + return "clearAllData" + } + } +} + +final class SoftLogoutCoordinator: Coordinator, Presentable { + // MARK: - Properties + + // MARK: Private + + private let parameters: SoftLogoutCoordinatorParameters + private let softLogoutHostingController: UIViewController + private var softLogoutViewModel: SoftLogoutViewModelProtocol + /// Passed to the OIDC service to provide a view controller from which to present the authentication session. + private let oidcUserAgent: OIDExternalUserAgentIOS? + + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var loadingIndicator: UserIndicator? + private var successIndicator: UserIndicator? + + /// The wizard used to handle the registration flow. + private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: (@MainActor (SoftLogoutCoordinatorResult) -> Void)? + + // MARK: - Setup + + @MainActor init(parameters: SoftLogoutCoordinatorParameters) { + self.parameters = parameters + + let homeserver = parameters.authenticationService.homeserver + + let viewModel = SoftLogoutViewModel(credentials: parameters.credentials, + homeserver: homeserver, + keyBackupNeeded: parameters.keyBackupNeeded) + softLogoutViewModel = viewModel + + let view = SoftLogoutScreen(context: viewModel.context) + softLogoutHostingController = UIHostingController(rootView: view) + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: softLogoutHostingController) + oidcUserAgent = OIDExternalUserAgentIOS(presenting: softLogoutHostingController) + } + + // MARK: - Public + + func start() { + MXLog.debug("[SoftLogoutCoordinator] did start.") + + softLogoutViewModel.callback = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[SoftLogoutCoordinator] SoftLogoutViewModel did complete with result: \(result).") + + switch result { + case .login(let password): + self.login(withPassword: password) + case .forgotPassword: + self.showForgotPasswordScreen() + case .clearAllData: + self.callback?(.clearAllData) + case .continueWithOIDC: + self.loginWithOIDC() + } + } + } + + func toPresentable() -> UIViewController { + softLogoutHostingController + } + + // MARK: - Private + + /// Show an activity indicator whilst loading. + @MainActor private func startLoading() { + loadingIndicator = indicatorPresenter.present(.loading(label: ElementL10n.loading, isInteractionBlocking: true)) + } + + /// Hide the currently displayed activity indicator. + @MainActor private func stopLoading() { + loadingIndicator = nil + } + + /// Shows the forgot password screen. + @MainActor private func showForgotPasswordScreen() { + MXLog.debug("[SoftLogoutCoordinator] showForgotPasswordScreen") + + softLogoutViewModel.displayError(.alert("Not implemented.")) + } + + /// Login with the supplied username and password. + @MainActor private func login(withPassword password: String) { + let username = parameters.credentials.userId + + startLoading() + + Task { + switch await authenticationService.login(username: username, + password: password, + initialDeviceName: UIDevice.current.initialDisplayName, + deviceId: parameters.credentials.deviceId) { + case .success(let userSession): + callback?(.signedIn(userSession)) + stopLoading() + case .failure(let error): + stopLoading() + handleError(error) + } + } + } + + private func loginWithOIDC() { + guard let oidcUserAgent = oidcUserAgent else { + handleError(AuthenticationServiceError.oidcError(.notSupported)) + return + } + + startLoading() + + Task { + switch await authenticationService.loginWithOIDC(userAgent: oidcUserAgent) { + case .success(let userSession): + callback?(.signedIn(userSession)) + stopLoading() + case .failure(let error): + stopLoading() + handleError(error) + } + } + } + + /// Processes an error to either update the flow or display it to the user. + private func handleError(_ error: AuthenticationServiceError) { + switch error { + case .invalidCredentials: + softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam)) + case .accountDeactivated: + softLogoutViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount)) + default: + softLogoutViewModel.displayError(.alert(ElementL10n.unknownError)) + } + } +} diff --git a/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutModels.swift b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutModels.swift new file mode 100644 index 000000000..a5889e828 --- /dev/null +++ b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutModels.swift @@ -0,0 +1,107 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +// MARK: Data + +struct SoftLogoutCredentials { + let userId: String + let homeserverName: String + let userDisplayName: String + let deviceId: String? +} + +// MARK: View model + +enum SoftLogoutViewModelAction: CustomStringConvertible { + /// Login with password + case login(String) + /// Forgot password + case forgotPassword + /// Clear all user data + case clearAllData + /// Continue using OIDC. + case continueWithOIDC + + /// A string representation of the result, ignoring any associated values that could leak PII. + var description: String { + switch self { + case .login: + return "login" + case .forgotPassword: + return "forgotPassword" + case .clearAllData: + return "clearAllData" + case .continueWithOIDC: + return "continueWithOIDC" + } + } +} + +// MARK: View + +struct SoftLogoutViewState: BindableState { + /// Soft logout credentials + var credentials: SoftLogoutCredentials + + /// Data about the selected homeserver. + var homeserver: LoginHomeserver + + /// Flag indicating soft logged out user needs backup for some keys + var keyBackupNeeded: Bool + + /// View state that can be bound to from SwiftUI. + var bindings: SoftLogoutBindings + + /// The types of login supported by the homeserver. + var loginMode: LoginMode { homeserver.loginMode } + + /// Whether to show recover encryption keys message + var showRecoverEncryptionKeysMessage: Bool { + keyBackupNeeded + } + + /// `true` when valid credentials have been entered and a homeserver has been loaded. + var canSubmit: Bool { + !bindings.password.isEmpty + } +} + +struct SoftLogoutBindings { + /// The password input by the user. + var password: String + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? +} + +enum SoftLogoutViewAction { + /// Login. + case login + /// Forgot password + case forgotPassword + /// Clear all user data. + case clearAllData + /// Continue using OIDC. + case continueWithOIDC +} + +enum SoftLogoutErrorType: Hashable { + /// A specific error message shown in an alert. + case alert(String) + /// An unknown error occurred. + case unknown +} diff --git a/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutViewModel.swift b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutViewModel.swift new file mode 100644 index 000000000..4264c5a9f --- /dev/null +++ b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutViewModel.swift @@ -0,0 +1,69 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +typealias SoftLogoutViewModelType = StateStoreViewModel + +class SoftLogoutViewModel: SoftLogoutViewModelType, SoftLogoutViewModelProtocol { + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var callback: (@MainActor (SoftLogoutViewModelAction) -> Void)? + + // MARK: - Setup + + init(credentials: SoftLogoutCredentials, + homeserver: LoginHomeserver, + keyBackupNeeded: Bool, + password: String = "") { + let bindings = SoftLogoutBindings(password: password) + let viewState = SoftLogoutViewState(credentials: credentials, + homeserver: homeserver, + keyBackupNeeded: keyBackupNeeded, + bindings: bindings) + super.init(initialViewState: viewState) + } + + // MARK: - Public + + override func process(viewAction: SoftLogoutViewAction) async { + switch viewAction { + case .login: + callback?(.login(state.bindings.password)) + case .forgotPassword: + callback?(.forgotPassword) + case .clearAllData: + callback?(.clearAllData) + case .continueWithOIDC: + callback?(.continueWithOIDC) + } + } + + @MainActor func displayError(_ type: SoftLogoutErrorType) { + switch type { + case .alert(let message): + state.bindings.alertInfo = AlertInfo(id: type, + title: ElementL10n.dialogTitleError, + message: message) + case .unknown: + state.bindings.alertInfo = AlertInfo(id: type) + } + } +} diff --git a/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutViewModelProtocol.swift b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutViewModelProtocol.swift new file mode 100644 index 000000000..9d8632bf3 --- /dev/null +++ b/ElementX/Sources/Screens/Authentication/SoftLogout/SoftLogoutViewModelProtocol.swift @@ -0,0 +1,25 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol SoftLogoutViewModelProtocol { + var callback: (@MainActor (SoftLogoutViewModelAction) -> Void)? { get set } + var context: SoftLogoutViewModelType.Context { get } + + /// Display an error to the user. + @MainActor func displayError(_ type: SoftLogoutErrorType) +} diff --git a/ElementX/Sources/Screens/Authentication/SoftLogout/View/SoftLogoutScreen.swift b/ElementX/Sources/Screens/Authentication/SoftLogout/View/SoftLogoutScreen.swift new file mode 100644 index 000000000..304ceb94f --- /dev/null +++ b/ElementX/Sources/Screens/Authentication/SoftLogout/View/SoftLogoutScreen.swift @@ -0,0 +1,190 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct SoftLogoutScreen: View { + // MARK: - Properties + + // MARK: Private + + /// The focus state of the password text field. + @FocusState private var isPasswordFocused: Bool + + // MARK: Public + + @ObservedObject var context: SoftLogoutViewModel.Context + + // MARK: Views + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + .padding(.top, UIConstants.topPaddingToNavigationBar) + .padding(.bottom, 36) + + switch context.viewState.loginMode { + case .password: + loginForm + case .oidc: + oidcButton + default: + loginUnavailableText + } + + clearDataForm + .padding(.top, 16) + } + .readableFrame() + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + .background(Color.element.background.ignoresSafeArea()) + .alert(item: $context.alertInfo) { $0.alert } + } + + /// The title, message and icon at the top of the screen. + var header: some View { + VStack(alignment: .leading, spacing: 16) { + Text(ElementL10n.softLogoutSigninTitle) + .font(.element.title2Bold) + .multilineTextAlignment(.leading) + .foregroundColor(.element.primaryContent) + .accessibilityIdentifier("titleLabel") + + Text(ElementL10n.softLogoutSigninNotice(context.viewState.credentials.homeserverName, context.viewState.credentials.userDisplayName, context.viewState.credentials.userId)) + .font(.element.body) + .multilineTextAlignment(.leading) + .foregroundColor(.element.primaryContent) + .accessibilityIdentifier("messageLabel1") + + if context.viewState.showRecoverEncryptionKeysMessage { + Text(ElementL10n.softLogoutSigninE2eWarningNotice) + .font(.element.body) + .multilineTextAlignment(.leading) + .foregroundColor(.element.primaryContent) + .accessibilityIdentifier("messageLabel2") + } + } + } + + /// The form with text fields for username and password, along with a submit button. + var loginForm: some View { + VStack(spacing: 14) { + SecureField(ElementL10n.loginSignupPasswordHint, text: $context.password) + .focused($isPasswordFocused) + .textFieldStyle(.elementInput()) + .textContentType(.password) + .submitLabel(.done) + .onSubmit(submit) + .accessibilityIdentifier("passwordTextField") + + Button { context.send(viewAction: .forgotPassword) } label: { + Text(ElementL10n.authenticationLoginForgotPassword) + .font(.element.body) + } + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.bottom, 8) + .accessibilityIdentifier("forgotPasswordButton") + + Button(action: submit) { + Text(ElementL10n.loginSignupSubmit) + } + .buttonStyle(.elementAction(.xLarge)) + .disabled(!context.viewState.canSubmit) + .accessibilityIdentifier("nextButton") + } + } + + /// The OIDC button that can be used for login. + var oidcButton: some View { + Button { context.send(viewAction: .continueWithOIDC) } label: { + Text(ElementL10n.loginContinue) + } + .buttonStyle(.elementAction(.xLarge)) + .accessibilityIdentifier("oidcButton") + } + + /// Text shown if neither password or OIDC login is supported. + var loginUnavailableText: some View { + Text(ElementL10n.autodiscoverWellKnownError) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.element.primaryContent) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("unsupportedServerText") + } + + /// The text field and submit button where the user enters an email address. + var clearDataForm: some View { + VStack(alignment: .leading, spacing: 12) { + Text(ElementL10n.softLogoutClearDataTitle) + .font(.element.title2Bold) + .multilineTextAlignment(.leading) + .foregroundColor(.element.primaryContent) + .accessibilityIdentifier("clearDataTitleLabel") + + Text(ElementL10n.softLogoutClearDataNotice) + .font(.element.body) + .multilineTextAlignment(.leading) + .foregroundColor(.element.primaryContent) + .accessibilityIdentifier("clearDataMessageLabel") + .padding(.bottom, 12) + + Button(action: clearData) { + Text(ElementL10n.softLogoutClearDataSubmit) + } + .buttonStyle(.elementAction(.xLarge, color: .element.alert)) + .accessibilityIdentifier("clearDataButton") + } + } + + /// Sends the `login` view action so long as a valid email address has been input. + func submit() { + guard context.viewState.canSubmit else { return } + context.send(viewAction: .login) + } + + /// Sends the `forgotPassword` view action. + func forgotPassword() { + context.send(viewAction: .forgotPassword) + } + + /// Sends the `clearAllData` view action. + func clearData() { + context.send(viewAction: .clearAllData) + } +} + +// MARK: - Previews + +struct SoftLogout_Previews: PreviewProvider { + static var previews: some View { + ForEach(MockSoftLogoutScreenState.allCases) { state in + screen(for: state.viewModel) + } + } + + static func screen(for viewModel: SoftLogoutViewModel) -> some View { + NavigationView { + SoftLogoutScreen(context: viewModel.context) + .navigationBarTitleDisplayMode(.inline) + .tint(.element.accent) + } + .navigationViewStyle(.stack) + } +} diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index ad838b699..858ccbf7d 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -90,6 +90,8 @@ final class HomeScreenCoordinator: Coordinator, Presentable { self?.viewModel.showSessionVerificationBanner() case .didVerifySession: self?.viewModel.hideSessionVerificationBanner() + default: + break } }.store(in: &cancellables) diff --git a/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift b/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift index 1a46ecd05..9133604ec 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationServiceProxy.swift @@ -104,11 +104,17 @@ class AuthenticationServiceProxy: AuthenticationServiceProxyProtocol { } } - func login(username: String, password: String) async -> Result { + func login(username: String, password: String, initialDeviceName: String?, deviceId: String?) async -> Result { Benchmark.startTrackingForIdentifier("Login", message: "Started new login") let loginTask: Task = Task.detached { - try self.authenticationService.login(username: username, password: password) + #warning("Use new api on next SDK release.") + return try self.authenticationService.login(username: username, + password: password) +// try self.authenticationService.login(username: username, +// password: password, +// initialDeviceName: initialDeviceName, +// deviceId: deviceId) } switch await loginTask.result { diff --git a/ElementX/Sources/Services/Authentication/AuthenticationServiceProxyProtocol.swift b/ElementX/Sources/Services/Authentication/AuthenticationServiceProxyProtocol.swift index 679e95547..8c4570a25 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationServiceProxyProtocol.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationServiceProxyProtocol.swift @@ -36,5 +36,5 @@ protocol AuthenticationServiceProxyProtocol { /// Performs login using OIDC for the current homeserver. func loginWithOIDC(userAgent: OIDExternalUserAgentIOS) async -> Result /// Performs a password login using the current homeserver. - func login(username: String, password: String) async -> Result + func login(username: String, password: String, initialDeviceName: String?, deviceId: String?) async -> Result } diff --git a/ElementX/Sources/Services/Authentication/MockAuthenticationServiceProxy.swift b/ElementX/Sources/Services/Authentication/MockAuthenticationServiceProxy.swift index afb191aac..b19d0f1d0 100644 --- a/ElementX/Sources/Services/Authentication/MockAuthenticationServiceProxy.swift +++ b/ElementX/Sources/Services/Authentication/MockAuthenticationServiceProxy.swift @@ -45,8 +45,8 @@ class MockAuthenticationServiceProxy: AuthenticationServiceProxyProtocol { func loginWithOIDC(userAgent: OIDExternalUserAgentIOS) async -> Result { .failure(.oidcError(.notSupported)) } - - func login(username: String, password: String) async -> Result { + + func login(username: String, password: String, initialDeviceName: String?, deviceId: String?) async -> Result { // Login only succeeds if the username and password match the valid credentials property guard username == validCredentials.username, password == validCredentials.password else { return .failure(.invalidCredentials) diff --git a/ElementX/Sources/Services/BugReport/ScreenshotDetector.swift b/ElementX/Sources/Services/BugReport/ScreenshotDetector.swift index dee0bce55..95997fecd 100644 --- a/ElementX/Sources/Services/BugReport/ScreenshotDetector.swift +++ b/ElementX/Sources/Services/BugReport/ScreenshotDetector.swift @@ -28,7 +28,7 @@ class ScreenshotDetector { var callback: (@MainActor (UIImage?, Error?) -> Void)? /// Flag to whether ask for photos authorization by default if needed. - var autoRequestPHAuthorization = true + var autoRequestPHAuthorization = false init() { startObservingScreenshots() diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index c358a9ef8..c4b6918ea 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -29,6 +29,14 @@ private class WeakClientProxyWrapper: ClientDelegate { func didReceiveSyncUpdate() { clientProxy?.didReceiveSyncUpdate() } + + func didReceiveAuthError(isSoftLogout: Bool) { + clientProxy?.didReceiveAuthError(isSoftLogout: isSoftLogout) + } + + func didUpdateRestoreToken() { + clientProxy?.didUpdateRestoreToken() + } } class ClientProxy: ClientProxyProtocol { @@ -57,11 +65,18 @@ class ClientProxy: ClientProxyProtocol { self.backgroundTaskService = backgroundTaskService client.setDelegate(delegate: WeakClientProxyWrapper(clientProxy: self)) - + + #warning("Use isSoftLogout() api on next SDK release.") Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.") client.startSync(timelineLimit: ClientProxy.syncLimit) - + Task { await updateRooms() } +// if !client.isSoftLogout() { +// Benchmark.startTrackingForIdentifier("ClientSync", message: "Started sync.") +// client.startSync(timelineLimit: ClientProxy.syncLimit) +// +// Task { await updateRooms() } +// } } var userIdentifier: String { @@ -72,6 +87,34 @@ class ClientProxy: ClientProxyProtocol { return "Unknown user identifier" } } + + var isSoftLogout: Bool { + #warning("Use isSoftLogout() api on next SDK release.") + return false +// client.isSoftLogout() + } + + var deviceId: String? { + do { + return try client.deviceId() + } catch { + MXLog.error("Failed retrieving device id with error: \(error)") + return nil + } + } + + var homeserver: String { + client.homeserver() + } + + var restoreToken: String? { + do { + return try client.restoreToken() + } catch { + MXLog.error("Failed retrieving restore token with error: \(error)") + return nil + } + } func loadUserDisplayName() async -> Result { await Task.detached { @@ -91,7 +134,7 @@ class ClientProxy: ClientProxyProtocol { let avatarURL = try self.client.avatarUrl() return .success(avatarURL) } catch { - return .failure(.failedRetrievingDisplayName) + return .failure(.failedRetrievingAvatarURL) } } .value @@ -125,6 +168,15 @@ class ClientProxy: ClientProxyProtocol { } .value } + + func logout() async { + do { + #warning("Use logout() api on next SDK release.") +// try client.logout() + } catch { + MXLog.error("Failed logging out with error: \(error)") + } + } // MARK: Private @@ -137,6 +189,14 @@ class ClientProxy: ClientProxyProtocol { await self.updateRooms() } } + + fileprivate func didReceiveAuthError(isSoftLogout: Bool) { + callbacks.send(.receivedAuthError(isSoftLogout: isSoftLogout)) + } + + fileprivate func didUpdateRestoreToken() { + callbacks.send(.updatedRestoreToken) + } private func updateRooms() async { var currentRooms = rooms diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 742954a18..cd94c69c2 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -21,6 +21,8 @@ import MatrixRustSDK enum ClientProxyCallback { case updatedRoomsList case receivedSyncUpdate + case receivedAuthError(isSoftLogout: Bool) + case updatedRestoreToken } enum ClientProxyError: Error { @@ -36,6 +38,14 @@ protocol ClientProxyProtocol { var callbacks: PassthroughSubject { get } var userIdentifier: String { get } + + var isSoftLogout: Bool { get } + + var deviceId: String? { get } + + var homeserver: String { get } + + var restoreToken: String? { get } var rooms: [RoomProxy] { get } @@ -52,4 +62,6 @@ protocol ClientProxyProtocol { func loadMediaContentForSource(_ source: MatrixRustSDK.MediaSource) throws -> Data func sessionVerificationControllerProxy() async -> Result + + func logout() async } diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 085707070..1a7f1b060 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -21,6 +21,10 @@ struct MockClientProxy: ClientProxyProtocol { let callbacks = PassthroughSubject() let userIdentifier: String + let isSoftLogout = false + let deviceId: String? = nil + let homeserver = "" + let restoreToken: String? = nil let rooms = [RoomProxy]() @@ -51,4 +55,8 @@ struct MockClientProxy: ClientProxyProtocol { func sessionVerificationControllerProxy() async -> Result { .failure(.failedRetrievingSessionVerificationController) } + + func logout() async { + // no-op + } } diff --git a/ElementX/Sources/Services/Session/MockUserSession.swift b/ElementX/Sources/Services/Session/MockUserSession.swift index 3c56f7bb7..1b8b2b657 100644 --- a/ElementX/Sources/Services/Session/MockUserSession.swift +++ b/ElementX/Sources/Services/Session/MockUserSession.swift @@ -21,6 +21,9 @@ struct MockUserSession: UserSessionProtocol { let sessionVerificationController: SessionVerificationControllerProxyProtocol? = nil var userID: String { clientProxy.userIdentifier } + var isSoftLogout: Bool { clientProxy.isSoftLogout } + var deviceId: String? { clientProxy.deviceId } + var homeserver: String { clientProxy.homeserver } let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol } diff --git a/ElementX/Sources/Services/Session/UserSession.swift b/ElementX/Sources/Services/Session/UserSession.swift index 3178fcba8..d131297c4 100644 --- a/ElementX/Sources/Services/Session/UserSession.swift +++ b/ElementX/Sources/Services/Session/UserSession.swift @@ -20,9 +20,14 @@ import Foundation class UserSession: UserSessionProtocol { private var cancellables = Set() private var checkForSessionVerificationControllerCancellable: AnyCancellable? + private var authErrorCancellable: AnyCancellable? + private var restoreTokenUpdateCancellable: AnyCancellable? var userID: String { clientProxy.userIdentifier } - + var isSoftLogout: Bool { clientProxy.isSoftLogout } + var deviceId: String? { clientProxy.deviceId } + var homeserver: String { clientProxy.homeserver } + let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol let callbacks = PassthroughSubject() @@ -33,6 +38,8 @@ class UserSession: UserSessionProtocol { self.mediaProvider = mediaProvider setupSessionVerificationWatchdog() + setupAuthErrorWatchdog() + setupRestoreTokenUpdateWatchdog() } // MARK: - Private @@ -77,4 +84,38 @@ class UserSession: UserSessionProtocol { private func tearDownSessionVerificationControllerWatchdog() { checkForSessionVerificationControllerCancellable = nil } + + // MARK: Auth Error Watchdog + + private func setupAuthErrorWatchdog() { + authErrorCancellable = clientProxy.callbacks + .receive(on: DispatchQueue.main) + .sink { [weak self] callback in + if case .receivedAuthError(let isSoftLogout) = callback { + self?.callbacks.send(.didReceiveAuthError(isSoftLogout: isSoftLogout)) + self?.tearDownAuthErrorWatchdog() + } + } + } + + private func tearDownAuthErrorWatchdog() { + authErrorCancellable = nil + } + + // MARK: Restore Token Update Watchdog + + private func setupRestoreTokenUpdateWatchdog() { + restoreTokenUpdateCancellable = clientProxy.callbacks + .receive(on: DispatchQueue.main) + .sink { [weak self] callback in + if case .updatedRestoreToken = callback { + self?.callbacks.send(.updateRestoreTokenNeeded) + self?.tearDownRestoreTokenUpdateWatchdog() + } + } + } + + private func tearDownRestoreTokenUpdateWatchdog() { + restoreTokenUpdateCancellable = nil + } } diff --git a/ElementX/Sources/Services/Session/UserSessionProtocol.swift b/ElementX/Sources/Services/Session/UserSessionProtocol.swift index 1cd14639f..da32bc626 100644 --- a/ElementX/Sources/Services/Session/UserSessionProtocol.swift +++ b/ElementX/Sources/Services/Session/UserSessionProtocol.swift @@ -20,10 +20,15 @@ import Foundation enum UserSessionCallback { case sessionVerificationNeeded case didVerifySession + case didReceiveAuthError(isSoftLogout: Bool) + case updateRestoreTokenNeeded } protocol UserSessionProtocol { var userID: String { get } + var isSoftLogout: Bool { get } + var deviceId: String? { get } + var homeserver: String { get } var clientProxy: ClientProxyProtocol { get } var mediaProvider: MediaProviderProtocol { get } diff --git a/ElementX/Sources/Services/UserSessionStore/UserSessionStore.swift b/ElementX/Sources/Services/UserSessionStore/UserSessionStore.swift index 2dd053192..52c243272 100644 --- a/ElementX/Sources/Services/UserSessionStore/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSessionStore/UserSessionStore.swift @@ -69,6 +69,16 @@ class UserSessionStore: UserSessionStoreProtocol { return .failure(error) } } + + func refreshRestoreToken(for userSession: UserSessionProtocol) -> Result { + guard let accessToken = userSession.clientProxy.restoreToken else { + return .failure(.failedRefreshingRestoreToken) + } + + keychainController.setRestoreToken(accessToken, forUsername: userSession.clientProxy.userIdentifier) + + return .success(()) + } func logout(userSession: UserSessionProtocol) { let userID = userSession.clientProxy.userIdentifier diff --git a/ElementX/Sources/Services/UserSessionStore/UserSessionStoreProtocol.swift b/ElementX/Sources/Services/UserSessionStore/UserSessionStoreProtocol.swift index 3ee73555a..419eb745e 100644 --- a/ElementX/Sources/Services/UserSessionStore/UserSessionStoreProtocol.swift +++ b/ElementX/Sources/Services/UserSessionStore/UserSessionStoreProtocol.swift @@ -21,6 +21,7 @@ enum UserSessionStoreError: Error { case missingCredentials case failedRestoringLogin case failedSettingUpSession + case failedRefreshingRestoreToken } @MainActor @@ -36,6 +37,9 @@ protocol UserSessionStoreProtocol { /// Creates a user session for a new client from the SDK. func userSession(for client: Client) async -> Result + + /// Refresh the restore token of the client for a given session. + func refreshRestoreToken(for userSession: UserSessionProtocol) -> Result /// Logs out of the specified session. func logout(userSession: UserSessionProtocol) diff --git a/ElementX/Sources/UITestScreenIdentifier.swift b/ElementX/Sources/UITestScreenIdentifier.swift index 17bbe7a6d..1f5652b3c 100644 --- a/ElementX/Sources/UITestScreenIdentifier.swift +++ b/ElementX/Sources/UITestScreenIdentifier.swift @@ -21,6 +21,7 @@ enum UITestScreenIdentifier: String { case serverSelection case serverSelectionNonModal case authenticationFlow + case softLogout case analyticsPrompt case simpleRegular case simpleUpgrade diff --git a/ElementX/Sources/UITestsAppCoordinator.swift b/ElementX/Sources/UITestsAppCoordinator.swift index f2fedd7bd..45a4d18eb 100644 --- a/ElementX/Sources/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITestsAppCoordinator.swift @@ -82,6 +82,14 @@ class MockScreen: Identifiable { case .authenticationFlow: return AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(), navigationRouter: navigationRouter) + case .softLogout: + let credentials = SoftLogoutCredentials(userId: "@mock:matrix.org", + homeserverName: "matrix.org", + userDisplayName: "mock", + deviceId: "ABCDEFGH") + return SoftLogoutCoordinator(parameters: .init(authenticationService: MockAuthenticationServiceProxy(), + credentials: credentials, + keyBackupNeeded: false)) case .simpleRegular: return TemplateCoordinator(parameters: .init(promptType: .regular)) case .simpleUpgrade: diff --git a/UITests/Sources/SoftLogoutUITests.swift b/UITests/Sources/SoftLogoutUITests.swift new file mode 100644 index 000000000..0c7cbff31 --- /dev/null +++ b/UITests/Sources/SoftLogoutUITests.swift @@ -0,0 +1,57 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import ElementX +import XCTest + +@MainActor +class SoftLogoutUITests: XCTestCase { + var app: XCUIApplication! + + @MainActor + override func setUp() async throws { + app = nil + } + + func testInitialState() { + app = Application.launch() + app.goToScreenWithIdentifier(.softLogout) + + XCTAssertTrue(app.staticTexts["titleLabel"].exists, "The title should be shown.") + XCTAssertTrue(app.staticTexts["messageLabel1"].exists, "The message 1 should be shown.") + XCTAssertTrue(app.staticTexts["clearDataTitleLabel"].exists, "The clear data title should be shown.") + XCTAssertTrue(app.staticTexts["clearDataMessageLabel"].exists, "The clear data message should be shown.") + + let passwordTextField = app.secureTextFields["passwordTextField"] + XCTAssertTrue(passwordTextField.exists, "The password text field should be shown.") + XCTAssertTrue(passwordTextField.label.isEmpty, "The password text field text should be empty before text is input.") + XCTAssertEqual(passwordTextField.placeholderValue, ElementL10n.loginSignupPasswordHint, "The password text field should be showing the placeholder before text is input.") + + let nextButton = app.buttons["nextButton"] + XCTAssertTrue(nextButton.exists, "The next button should be shown.") + XCTAssertFalse(nextButton.isEnabled, "The next button should be disabled before text is input.") + + let forgotPasswordButton = app.buttons["forgotPasswordButton"] + XCTAssertTrue(forgotPasswordButton.exists, "The forgot password button should be shown.") + XCTAssertTrue(forgotPasswordButton.isEnabled, "The forgot password button should be enabled.") + + let clearDataButton = app.buttons["clearDataButton"] + XCTAssertTrue(clearDataButton.exists, "The clear data button should be shown.") + XCTAssertTrue(clearDataButton.isEnabled, "The clear data button should be enabled.") + + app.assertScreenshot(.softLogout) + } +} diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.softLogout.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.softLogout.png new file mode 100644 index 000000000..1598c07af --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPad-9th-generation.softLogout.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:251a91df282cc57f5014657fd72babd14dfd6b052943d33651d2ec2bffd363f6 +size 125943 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.softLogout.png b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.softLogout.png new file mode 100644 index 000000000..e0cd1e43c --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/15-5-de-DE-iPhone-13-Pro-Max.softLogout.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f3f78efc97f56080f8c10e411104ae24ead1872316be3bf88cd4d5cecdd6e9c +size 162578 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.softLogout.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.softLogout.png new file mode 100644 index 000000000..11b1d3021 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPad-9th-generation.softLogout.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb7fa08f6d0bef9fd94ea9b40b39724ffbdf27b0ffb0e84eabe8fe00903e2d3f +size 125863 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.softLogout.png b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.softLogout.png new file mode 100644 index 000000000..7a6b7ad0f --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/15-5-en-GB-iPhone-13-Pro-Max.softLogout.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4c99d6c7f66150e73b8d12381c2161f8bc681b2488cbb042b26623805994738 +size 162369 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.softLogout.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.softLogout.png new file mode 100644 index 000000000..b06da514a --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPad-9th-generation.softLogout.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca80888ec562d5f0dc82133ba690e4b8a4dd9f546013662b43b301ed8168ab03 +size 127108 diff --git a/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.softLogout.png b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.softLogout.png new file mode 100644 index 000000000..6bbfbfc99 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/15-5-fr-FR-iPhone-13-Pro-Max.softLogout.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d76e249f29ab646ac6d911aea1277495b0670e7dffd8e0271170fa60fe08ab0a +size 163890 diff --git a/UnitTests/Sources/SoftLogoutViewModelTests.swift b/UnitTests/Sources/SoftLogoutViewModelTests.swift new file mode 100644 index 000000000..212458a4b --- /dev/null +++ b/UnitTests/Sources/SoftLogoutViewModelTests.swift @@ -0,0 +1,91 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import ElementX + +class SoftLogoutViewModelTests: XCTestCase { + let credentials = SoftLogoutCredentials(userId: "mock_user_id", + homeserverName: "https://matrix.org", + userDisplayName: "mock_username", + deviceId: "ABCDEFGH") + + @MainActor func testInitialStateForMatrixOrg() { + let viewModel = SoftLogoutViewModel(credentials: credentials, + homeserver: .mockMatrixDotOrg, + keyBackupNeeded: true) + let context = viewModel.context + + // Given a view model where the user hasn't yet sent the verification email. + XCTAssert(context.password.isEmpty, "The view model should start with an empty password.") + XCTAssertFalse(context.viewState.canSubmit, "The view model should start with an invalid password.") + XCTAssertEqual(context.viewState.loginMode, .password, "The view model should show login form for the given homeserver.") + XCTAssert(context.viewState.showRecoverEncryptionKeysMessage, "The view model should show recover encryption keys message.") + } + + @MainActor func testInitialStateForMatrixOrgPasswordEntered() { + let viewModel = SoftLogoutViewModel(credentials: credentials, + homeserver: .mockMatrixDotOrg, + keyBackupNeeded: true, + password: "12345678") + let context = viewModel.context + + // Given a view model where the user hasn't yet sent the verification email. + XCTAssertTrue(context.viewState.canSubmit, "The view model should start with a valid password.") + XCTAssertEqual(context.viewState.loginMode, .password, "The view model should show login form for the given homeserver.") + XCTAssert(context.viewState.showRecoverEncryptionKeysMessage, "The view model should show recover encryption keys message.") + } + + @MainActor func testInitialStateForBasicServer() { + let viewModel = SoftLogoutViewModel(credentials: credentials, + homeserver: .mockBasicServer, + keyBackupNeeded: false) + let context = viewModel.context + + // Given a view model where the user hasn't yet sent the verification email. + XCTAssert(context.password.isEmpty, "The view model should start with an empty password.") + XCTAssertFalse(context.viewState.canSubmit, "The view model should start with an invalid password.") + XCTAssertEqual(context.viewState.loginMode, .password, "The view model should show login form for the given homeserver.") + XCTAssertFalse(context.viewState.showRecoverEncryptionKeysMessage, "The view model should not show recover encryption keys message.") + } + + @MainActor func testInitialStateForOIDC() { + let viewModel = SoftLogoutViewModel(credentials: credentials, + homeserver: .mockOIDC, + keyBackupNeeded: false) + let context = viewModel.context + + // Given a view model where the user hasn't yet sent the verification email. + XCTAssert(context.password.isEmpty, "The view model should start with an empty password.") + XCTAssertFalse(context.viewState.canSubmit, "The view model should start with an invalid password.") + XCTAssertTrue(context.viewState.loginMode.supportsOIDCFlow, "The view model should show OIDC button for the given homeserver.") + XCTAssertFalse(context.viewState.showRecoverEncryptionKeysMessage, "The view model should not show recover encryption keys message.") + } + + @MainActor func testInitialStateForUnsupported() { + let viewModel = SoftLogoutViewModel(credentials: credentials, + homeserver: .mockUnsupported, + keyBackupNeeded: false) + let context = viewModel.context + + // Given a view model where the user hasn't yet sent the verification email. + XCTAssert(context.password.isEmpty, "The view model should start with an empty password.") + XCTAssertFalse(context.viewState.canSubmit, "The view model should start with an invalid password.") + XCTAssertEqual(context.viewState.loginMode, .unsupported, "The view model should show unsupported text for the given homeserver.") + XCTAssertFalse(context.viewState.showRecoverEncryptionKeysMessage, "The view model should not show recover encryption keys message.") + } +} diff --git a/changelog.d/104.feature b/changelog.d/104.feature new file mode 100644 index 000000000..39ecedcf5 --- /dev/null +++ b/changelog.d/104.feature @@ -0,0 +1 @@ +Logout from the server & implement soft logout flow.